From b2346f98cd5e83d1db691c8c4ca87a761cd2b388 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 01:22:02 -0700 Subject: [PATCH 01/39] test: cover repeated theme reload requests --- .../CMUXCLIErrorOutputRegressionTests.swift | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 85691b0f5061..e2c9d1cea378 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -103,6 +103,68 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { ) } + func testThemesSetReloadsRunningAppAfterEveryThemeWrite() throws { + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-themes-socket-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let resourcesURL = root.appendingPathComponent("resources", isDirectory: true) + let themesURL = resourcesURL.appendingPathComponent("themes", isDirectory: true) + try fileManager.createDirectory(at: themesURL, withIntermediateDirectories: true) + try writeTheme(named: "Theme A", background: "#101010", to: themesURL) + try writeTheme(named: "Theme B", background: "#f8f8f8", to: themesURL) + try writeTheme(named: "Theme C", background: "#003b49", to: themesURL) + + let socketPath = "/tmp/cmux-theme-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: "OK") + defer { responder.stop() } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CFFIXED_USER_HOME"] = root.path + environment["HOME"] = root.path + environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.issue-4355-test" + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let configURL = root + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("com.cmuxterm.app", isDirectory: true) + .appendingPathComponent("config.ghostty", isDirectory: false) + + var observedThemeValues: [String] = [] + for themeName in ["Theme A", "Theme B", "Theme C"] { + let result = runProcess( + executablePath: cliPath, + arguments: ["themes", "set", themeName], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + observedThemeValues.append(try managedThemeValue(in: configURL)) + } + + XCTAssertEqual(observedThemeValues, [ + "light:Theme A,dark:Theme A", + "light:Theme B,dark:Theme B", + "light:Theme C,dark:Theme C", + ]) + XCTAssertEqual(responder.receivedRequests, [ + "reload_config", + "reload_config", + "reload_config", + ]) + } + private func bundledCLIPath() throws -> String { let fileManager = FileManager.default let appBundleURL = Bundle(for: Self.self) @@ -132,6 +194,36 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { return directory.appendingPathComponent("cmux.sock", isDirectory: false) } + private func writeTheme(named name: String, background: String, to directory: URL) throws { + try """ + background = \(background) + foreground = #eeeeee + cursor-color = #ff00ff + cursor-text = #000000 + """.write( + to: directory.appendingPathComponent(name, isDirectory: false), + atomically: true, + encoding: .utf8 + ) + } + + private func managedThemeValue(in configURL: URL) throws -> String { + let contents = try String(contentsOf: configURL, encoding: .utf8) + let values = contents.components(separatedBy: .newlines).compactMap { line -> String? in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { return nil } + let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { + return nil + } + return parts[1] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + return try XCTUnwrap(values.last) + } + private func fakeTaggedBundledCLIPath( sourceCLIPath: String, tagSlug: String, @@ -269,6 +361,7 @@ private final class UnixSocketResponder { private let queue = DispatchQueue(label: "com.cmux.tests.unix-socket-responder") private let lock = NSLock() private var stopped = false + private var requests: [String] = [] private var listenerFD: Int32 = -1 init(path: String, response: String) throws { @@ -326,6 +419,12 @@ private final class UnixSocketResponder { stop() } + var receivedRequests: [String] { + lock.lock() + defer { lock.unlock() } + return requests + } + func stop() { lock.lock() guard !stopped else { @@ -379,6 +478,12 @@ private final class UnixSocketResponder { guard !request.isEmpty else { return } + if let line = String(data: request, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) { + lock.lock() + requests.append(line) + lock.unlock() + } let payload = response + "\n" payload.withCString { pointer in _ = write(clientFD, pointer, strlen(pointer)) From 1be9d26c3d79c92eff85fd8693973b5d893cd29b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 01:23:42 -0700 Subject: [PATCH 02/39] fix: reload themes through cmux socket --- CLI/CMUXCLI+ThemeSupport.swift | 27 ++++++++++++++++++++- CLI/CMUXCLI+Themes.swift | 44 +++++++++++++++++++++++++++------- CLI/cmux.swift | 4 +++- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 9d0ea2404232..8db2673f79df 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -309,8 +309,15 @@ extension CMUXCLI { return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "") } - func reloadThemesIfPossible() -> ThemeReloadStatus { + func reloadThemesIfPossible( + socketPath: String, + explicitPassword: String? + ) -> ThemeReloadStatus { let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + if requestThemeReloadOverSocket(socketPath: socketPath, explicitPassword: explicitPassword) { + return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) + } + DistributedNotificationCenter.default().post( name: Notification.Name(Self.cmuxThemesReloadNotificationName), object: nil, @@ -319,6 +326,24 @@ extension CMUXCLI { return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) } + private func requestThemeReloadOverSocket(socketPath: String, explicitPassword: String?) -> Bool { + let client = SocketClient(path: socketPath) + do { + try client.connect() + defer { client.close() } + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) + let response = try client.send(command: "reload_config") + return !response.hasPrefix("ERROR:") + } catch { + client.close() + return false + } + } + func currentCmuxAppBundleIdentifier() -> String? { if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index 4e94e6397da5..e4eb7f42cb69 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -169,7 +169,12 @@ extension CMUXCLI { return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true) } - func runThemes(commandArgs: [String], jsonOutput: Bool) throws { + func runThemes( + commandArgs: [String], + jsonOutput: Bool, + socketPath: String, + explicitPassword: String? + ) throws { if commandArgs.isEmpty { if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { try runInteractiveThemes() @@ -193,13 +198,19 @@ extension CMUXCLI { case "set": try runThemesSet( args: Array(commandArgs.dropFirst()), - jsonOutput: jsonOutput + jsonOutput: jsonOutput, + socketPath: socketPath, + explicitPassword: explicitPassword ) case "clear": if commandArgs.count > 1 { throw CLIError(message: "themes clear does not take any positional arguments") } - try runThemesClear(jsonOutput: jsonOutput) + try runThemesClear( + jsonOutput: jsonOutput, + socketPath: socketPath, + explicitPassword: explicitPassword + ) default: if subcommand.hasPrefix("-") { throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.") @@ -207,7 +218,9 @@ extension CMUXCLI { try runThemesSet( args: commandArgs, - jsonOutput: jsonOutput + jsonOutput: jsonOutput, + socketPath: socketPath, + explicitPassword: explicitPassword ) } } @@ -265,7 +278,12 @@ extension CMUXCLI { } } - private func runThemesSet(args: [String], jsonOutput: Bool) throws { + private func runThemesSet( + args: [String], + jsonOutput: Bool, + socketPath: String, + explicitPassword: String? + ) throws { let (lightOpt, rem0) = parseOption(args, name: "--light") let (darkOpt, rem1) = parseOption(rem0, name: "--dark") @@ -300,7 +318,10 @@ extension CMUXCLI { } let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue) - let reloadStatus = reloadThemesIfPossible() + let reloadStatus = reloadThemesIfPossible( + socketPath: socketPath, + explicitPassword: explicitPassword + ) if jsonOutput { let payload: [String: Any] = [ @@ -321,9 +342,16 @@ extension CMUXCLI { ) } - private func runThemesClear(jsonOutput: Bool) throws { + private func runThemesClear( + jsonOutput: Bool, + socketPath: String, + explicitPassword: String? + ) throws { let configURL = try clearManagedThemeOverride() - let reloadStatus = reloadThemesIfPossible() + let reloadStatus = reloadThemesIfPossible( + socketPath: socketPath, + explicitPassword: explicitPassword + ) if jsonOutput { let payload: [String: Any] = [ diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1e8bee94704e..1c1d5f596bea 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2267,7 +2267,9 @@ struct CMUXCLI { if command == "themes" { try runThemes( commandArgs: commandArgs, - jsonOutput: jsonOutput + jsonOutput: jsonOutput, + socketPath: resolvedSocketPath, + explicitPassword: socketPasswordArg ) return } From a270a92709cf9679f8945d7b00c109afaf4da0fe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 01:39:44 -0700 Subject: [PATCH 03/39] fix: close theme reload socket once --- CLI/CMUXCLI+ThemeSupport.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 8db2673f79df..2269abefe58b 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -328,9 +328,10 @@ extension CMUXCLI { private func requestThemeReloadOverSocket(socketPath: String, explicitPassword: String?) -> Bool { let client = SocketClient(path: socketPath) + defer { client.close() } + do { try client.connect() - defer { client.close() } try authenticateClientIfNeeded( client, explicitPassword: explicitPassword, @@ -339,7 +340,6 @@ extension CMUXCLI { let response = try client.send(command: "reload_config") return !response.hasPrefix("ERROR:") } catch { - client.close() return false } } From 43ce3c9385345f50cfd554ed32ef736f8f400c41 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 01:40:38 -0700 Subject: [PATCH 04/39] test: hard kill timed out cli children --- cmuxTests/CMUXCLIErrorOutputRegressionTests.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 5f1470081a72..3e43653aef87 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -394,7 +394,11 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut if timedOut { process.terminate() - _ = exitSignal.wait(timeout: .now() + 1) + if exitSignal.wait(timeout: .now() + 1) == .timedOut, + process.isRunning { + kill(process.processIdentifier, SIGKILL) + _ = exitSignal.wait(timeout: .now() + 1) + } } return ProcessRunResult( @@ -434,7 +438,11 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut if timedOut { process.terminate() - _ = exitSignal.wait(timeout: .now() + 1) + if exitSignal.wait(timeout: .now() + 1) == .timedOut, + process.isRunning { + kill(process.processIdentifier, SIGKILL) + _ = exitSignal.wait(timeout: .now() + 1) + } } return ProcessRunResult( From 935c7c9899392347b205cea5a9f741a5ad3651c7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:09:44 -0700 Subject: [PATCH 05/39] fix: avoid terminal theme sync during settings init --- .../KeyboardShortcutSettingsFileStore.swift | 18 +++++- ...hortcutSettingsFileStoreStartupTests.swift | 60 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 12039da44a6d..8275bdb12ce1 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -54,6 +54,7 @@ final class CmuxSettingsFileStore { private let fallbackPaths: [String] private let fileManager: FileManager private let notificationCenter: NotificationCenter + private let applyAppearanceMode: (_ rawValue: String?, _ source: String, _ synchronizeTerminalTheme: Bool) -> Void private let stateLock = NSLock() private var primaryWatcher: ShortcutSettingsFileWatcher? @@ -66,6 +67,7 @@ final class CmuxSettingsFileStore { private var importedManagedDefaults: [String: ManagedSettingsValue] = [:] private var activeManagedCustomSettings = ManagedCustomSettings() private var isApplyingManagedSettings = false + private var hasCompletedInitialReload = false private(set) var activeSourcePath: String? init( @@ -74,6 +76,13 @@ final class CmuxSettingsFileStore { additionalFallbackPaths: [String] = [CmuxSettingsFileStore.defaultApplicationSupportFallbackPath].compactMap { $0 }, fileManager: FileManager = .default, notificationCenter: NotificationCenter = .default, + applyAppearanceMode: @escaping (_ rawValue: String?, _ source: String, _ synchronizeTerminalTheme: Bool) -> Void = { rawValue, source, synchronizeTerminalTheme in + AppearanceSettings.applyStoredMode( + rawValue: rawValue, + source: source, + synchronizeTerminalTheme: synchronizeTerminalTheme + ) + }, startWatching: Bool = true ) { self.primaryPath = primaryPath @@ -81,10 +90,12 @@ final class CmuxSettingsFileStore { .filter { $0 != primaryPath } self.fileManager = fileManager self.notificationCenter = notificationCenter + self.applyAppearanceMode = applyAppearanceMode importedManagedDefaults = Self.loadImportedManagedDefaults() bootstrapPrimaryTemplateIfNeeded() reload() + hasCompletedInitialReload = true guard startWatching else { return } primaryWatcher = ShortcutSettingsFileWatcher(path: primaryPath, fileManager: fileManager) { [weak self] in @@ -1217,6 +1228,8 @@ final class CmuxSettingsFileStore { let language = defaultsKey == LanguageSettings.languageKey ? AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system : nil let shouldApplyAppearance = defaultsKey == AppearanceSettings.appearanceModeKey let appearanceRawValue = shouldApplyAppearance ? UserDefaults.standard.string(forKey: defaultsKey) : nil + let shouldSynchronizeTerminalTheme = shouldApplyAppearance ? hasCompletedInitialReload : true + let applyAppearanceMode = self.applyAppearanceMode let appIconMode = defaultsKey == AppIconSettings.modeKey ? AppIconSettings.resolvedMode() : nil let apply = { if notifyScrollBar { @@ -1226,7 +1239,10 @@ final class CmuxSettingsFileStore { if let language { LanguageSettings.apply(language) } else if shouldApplyAppearance { - AppearanceSettings.applyStoredMode(rawValue: appearanceRawValue, source: source) + // The initial reload happens while KeyboardShortcutSettings.settingsFileStore's + // dispatch_once is still running. Synchronizing the terminal theme here can create + // GhosttyApp.shared, whose startup callbacks may request another settings reload. + applyAppearanceMode(appearanceRawValue, source, shouldSynchronizeTerminalTheme) } else if let appIconMode { AppIconSettings.applyIcon(appIconMode) } diff --git a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift index 6988914c96c1..77765871036d 100644 --- a/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift @@ -345,6 +345,66 @@ final class KeyboardShortcutSettingsFileStoreStartupTests: XCTestCase { } } + func testManagedAppearanceInitialReloadDoesNotSynchronizeTerminalThemeUntilSubsequentReload() throws { + let defaults = UserDefaults.standard + let key = AppearanceSettings.appearanceModeKey + + try preservingDefaults(keys: [key, settingsFileBackupsDefaultsKey, importedManagedDefaultsKey]) { + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + defaults.removeObject(forKey: importedManagedDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "appearance": "dark" + } + } + """, + to: settingsFileURL + ) + + var appearanceApplications: [(rawValue: String?, source: String, synchronizeTerminalTheme: Bool)] = [] + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + additionalFallbackPaths: [], + applyAppearanceMode: { rawValue, source, synchronizeTerminalTheme in + appearanceApplications.append((rawValue, source, synchronizeTerminalTheme)) + }, + startWatching: false + ) + + XCTAssertEqual(defaults.string(forKey: key), AppearanceMode.dark.rawValue) + XCTAssertEqual(appearanceApplications.map { $0.rawValue }, [AppearanceMode.dark.rawValue]) + XCTAssertEqual(appearanceApplications.map { $0.synchronizeTerminalTheme }, [false]) + + try writeSettingsFile( + """ + { + "app": { + "appearance": "light" + } + } + """, + to: settingsFileURL + ) + store.reload() + + XCTAssertEqual(defaults.string(forKey: key), AppearanceMode.light.rawValue) + XCTAssertEqual( + appearanceApplications.map { $0.rawValue }, + [AppearanceMode.dark.rawValue, AppearanceMode.light.rawValue] + ) + XCTAssertEqual(appearanceApplications.map { $0.synchronizeTerminalTheme }, [false, true]) + } + } + func testManagedBoolUserDefaultSurvivesSettingsFileReapplyUntilFileChanges() throws { let defaults = UserDefaults.standard let key = QuitWarningSettings.warnBeforeQuitKey From d11e332856b9ad1f2d3f425038c6ea3f42321636 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:21:20 -0700 Subject: [PATCH 06/39] test: cover skipped ghostty theme helper fallback --- .../CMUXCLIErrorOutputRegressionTests.swift | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 3e43653aef87..787946778ad5 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -189,6 +189,58 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { ]) } + func testBareThemesWithSkippedGhosttyHelperFallsBackToList() throws { + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-themes-stub-helper-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let fakeCLIPath = try fakeTaggedBundledCLIPath( + sourceCLIPath: cliPath, + tagSlug: "theme-stub-\(UUID().uuidString.lowercased())" + ) + let fakeBinURL = URL(fileURLWithPath: fakeCLIPath).deletingLastPathComponent() + let fakeResourcesURL = fakeBinURL.deletingLastPathComponent() + let fakeGhosttyResourcesURL = fakeResourcesURL.appendingPathComponent("ghostty", isDirectory: true) + let fakeThemesURL = fakeGhosttyResourcesURL.appendingPathComponent("themes", isDirectory: true) + try fileManager.createDirectory(at: fakeThemesURL, withIntermediateDirectories: true) + try writeTheme(named: "Fallback Stub Theme", background: "#101820", to: fakeThemesURL) + + let fakeGhosttyHelperURL = fakeBinURL.appendingPathComponent("ghostty", isDirectory: false) + try """ + #!/bin/sh + echo "ghostty CLI helper stub (zig build skipped)" >&2 + exit 1 + """.write(to: fakeGhosttyHelperURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: fakeGhosttyHelperURL.path + ) + + let command = [ + "env", + "-i", + "HOME=\(shellSingleQuote(root.path))", + "CFFIXED_USER_HOME=\(shellSingleQuote(root.path))", + "GHOSTTY_RESOURCES_DIR=\(shellSingleQuote(fakeGhosttyResourcesURL.path))", + "CMUX_CLI_SENTRY_DISABLED=1", + "PATH=/usr/bin:/bin", + "/usr/bin/script", + "-q", + "/dev/null", + shellSingleQuote(fakeCLIPath), + "themes", + ].joined(separator: " ") + let result = runShell(command, timeout: 5) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertTrue(result.stdout.contains("Fallback Stub Theme"), result.stdout) + XCTAssertFalse(result.stdout.contains("ghostty CLI helper stub"), result.stdout) + } + func testBrowserDownloadWaitUsesRequestedTimeoutForSocketResponse() throws { let cliPath = try bundledCLIPath() let socketPath = "/tmp/cmux-dw-\(UUID().uuidString.prefix(8)).sock" From 47a1de44921f45124d1c8f76295f7bde9807553a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:21:48 -0700 Subject: [PATCH 07/39] fix: ignore skipped ghostty theme helper stub --- CLI/CMUXCLI+Themes.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index e4eb7f42cb69..4f20ffcceb64 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -32,7 +32,8 @@ extension CMUXCLI { private func runInteractiveThemes() throws { guard let helperURL = bundledHelperURL(named: "ghostty") else { - throw CLIError(message: "Bundled Ghostty theme picker helper not found") + try printThemesList(jsonOutput: false) + return } let selection = currentThemeSelection() @@ -108,7 +109,21 @@ extension CMUXCLI { current = parent } - return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) + return candidates.first { candidate in + fileManager.isExecutableFile(atPath: candidate.path) + && !isSkippedGhosttyHelperStub(candidate) + } + } + + private func isSkippedGhosttyHelperStub(_ helperURL: URL) -> Bool { + let fileManager = FileManager.default + guard let attributes = try? fileManager.attributesOfItem(atPath: helperURL.path), + let size = attributes[.size] as? NSNumber, + size.intValue <= 4096, + let contents = try? String(contentsOf: helperURL, encoding: .utf8) else { + return false + } + return contents.contains("ghostty CLI helper stub (zig build skipped)") } private func execInteractiveHelper( From e24a87365ee5457bb04cd720c776f2d43b373949 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:32:08 -0700 Subject: [PATCH 08/39] Revert "fix: ignore skipped ghostty theme helper stub" This reverts commit 47a1de44921f45124d1c8f76295f7bde9807553a. --- CLI/CMUXCLI+Themes.swift | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index 4f20ffcceb64..e4eb7f42cb69 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -32,8 +32,7 @@ extension CMUXCLI { private func runInteractiveThemes() throws { guard let helperURL = bundledHelperURL(named: "ghostty") else { - try printThemesList(jsonOutput: false) - return + throw CLIError(message: "Bundled Ghostty theme picker helper not found") } let selection = currentThemeSelection() @@ -109,21 +108,7 @@ extension CMUXCLI { current = parent } - return candidates.first { candidate in - fileManager.isExecutableFile(atPath: candidate.path) - && !isSkippedGhosttyHelperStub(candidate) - } - } - - private func isSkippedGhosttyHelperStub(_ helperURL: URL) -> Bool { - let fileManager = FileManager.default - guard let attributes = try? fileManager.attributesOfItem(atPath: helperURL.path), - let size = attributes[.size] as? NSNumber, - size.intValue <= 4096, - let contents = try? String(contentsOf: helperURL, encoding: .utf8) else { - return false - } - return contents.contains("ghostty CLI helper stub (zig build skipped)") + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) } private func execInteractiveHelper( From b4cba6c88dbdf34e53ceebad67ae13c0dda1c842 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:32:08 -0700 Subject: [PATCH 09/39] Revert "test: cover skipped ghostty theme helper fallback" This reverts commit d11e332856b9ad1f2d3f425038c6ea3f42321636. --- .../CMUXCLIErrorOutputRegressionTests.swift | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 787946778ad5..3e43653aef87 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -189,58 +189,6 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { ]) } - func testBareThemesWithSkippedGhosttyHelperFallsBackToList() throws { - let cliPath = try bundledCLIPath() - let fileManager = FileManager.default - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-themes-stub-helper-\(UUID().uuidString)", isDirectory: true) - try fileManager.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: root) } - - let fakeCLIPath = try fakeTaggedBundledCLIPath( - sourceCLIPath: cliPath, - tagSlug: "theme-stub-\(UUID().uuidString.lowercased())" - ) - let fakeBinURL = URL(fileURLWithPath: fakeCLIPath).deletingLastPathComponent() - let fakeResourcesURL = fakeBinURL.deletingLastPathComponent() - let fakeGhosttyResourcesURL = fakeResourcesURL.appendingPathComponent("ghostty", isDirectory: true) - let fakeThemesURL = fakeGhosttyResourcesURL.appendingPathComponent("themes", isDirectory: true) - try fileManager.createDirectory(at: fakeThemesURL, withIntermediateDirectories: true) - try writeTheme(named: "Fallback Stub Theme", background: "#101820", to: fakeThemesURL) - - let fakeGhosttyHelperURL = fakeBinURL.appendingPathComponent("ghostty", isDirectory: false) - try """ - #!/bin/sh - echo "ghostty CLI helper stub (zig build skipped)" >&2 - exit 1 - """.write(to: fakeGhosttyHelperURL, atomically: true, encoding: .utf8) - try fileManager.setAttributes( - [.posixPermissions: 0o755], - ofItemAtPath: fakeGhosttyHelperURL.path - ) - - let command = [ - "env", - "-i", - "HOME=\(shellSingleQuote(root.path))", - "CFFIXED_USER_HOME=\(shellSingleQuote(root.path))", - "GHOSTTY_RESOURCES_DIR=\(shellSingleQuote(fakeGhosttyResourcesURL.path))", - "CMUX_CLI_SENTRY_DISABLED=1", - "PATH=/usr/bin:/bin", - "/usr/bin/script", - "-q", - "/dev/null", - shellSingleQuote(fakeCLIPath), - "themes", - ].joined(separator: " ") - let result = runShell(command, timeout: 5) - - XCTAssertFalse(result.timedOut, result.stdout) - XCTAssertEqual(result.status, 0, result.stdout) - XCTAssertTrue(result.stdout.contains("Fallback Stub Theme"), result.stdout) - XCTAssertFalse(result.stdout.contains("ghostty CLI helper stub"), result.stdout) - } - func testBrowserDownloadWaitUsesRequestedTimeoutForSocketResponse() throws { let cliPath = try bundledCLIPath() let socketPath = "/tmp/cmux-dw-\(UUID().uuidString.prefix(8)).sock" From d3022abb5a54ebf7c2f99f8e040e72c62ecb6224 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:43:54 -0700 Subject: [PATCH 10/39] test: cover interactive theme picker reload --- .../CMUXCLIErrorOutputRegressionTests.swift | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 3e43653aef87..7cd2408f91af 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -189,6 +189,55 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { ]) } + func testBareInteractiveThemesReloadsRunningAppAfterPickerExits() throws { + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-themes-picker-socket-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let fakeCLIPath = try fakeTaggedBundledCLIPath( + sourceCLIPath: cliPath, + tagSlug: "theme-picker-\(UUID().uuidString.lowercased())" + ) + let fakeGhosttyHelperURL = URL(fileURLWithPath: fakeCLIPath) + .deletingLastPathComponent() + .appendingPathComponent("ghostty", isDirectory: false) + try """ + #!/bin/sh + exit 0 + """.write(to: fakeGhosttyHelperURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: fakeGhosttyHelperURL.path + ) + + let socketPath = "/tmp/cmux-theme-picker-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: "OK") + defer { responder.stop() } + + let command = [ + "env", + "-i", + "HOME=\(shellSingleQuote(root.path))", + "CFFIXED_USER_HOME=\(shellSingleQuote(root.path))", + "CMUX_SOCKET_PATH=\(shellSingleQuote(socketPath))", + "CMUX_CLI_SENTRY_DISABLED=1", + "PATH=/usr/bin:/bin", + "/usr/bin/script", + "-q", + "/dev/null", + shellSingleQuote(fakeCLIPath), + "themes", + ].joined(separator: " ") + let result = runShell(command, timeout: 5) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertEqual(responder.receivedRequests, ["reload_config"]) + } + func testBrowserDownloadWaitUsesRequestedTimeoutForSocketResponse() throws { let cliPath = try bundledCLIPath() let socketPath = "/tmp/cmux-dw-\(UUID().uuidString.prefix(8)).sock" From 8a4e57cf7db118faaa9a6e0409b4c63afde36d57 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:44:41 -0700 Subject: [PATCH 11/39] fix: reload after interactive theme picker --- CLI/CMUXCLI+Themes.swift | 49 ++++++++++++++++++++++----------------- Sources/AppDelegate.swift | 10 ++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index e4eb7f42cb69..076ee482c5d4 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -30,7 +30,10 @@ extension CMUXCLI { return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 } - private func runInteractiveThemes() throws { + private func runInteractiveThemes( + socketPath: String, + explicitPassword: String? + ) throws { guard let helperURL = bundledHelperURL(named: "ghostty") else { throw CLIError(message: "Bundled Ghostty theme picker helper not found") } @@ -51,11 +54,12 @@ extension CMUXCLI { environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path } - try execInteractiveHelper( + try runInteractiveHelper( executablePath: helperURL.path, arguments: ["+list-themes"], environment: environment ) + _ = reloadThemesIfPossible(socketPath: socketPath, explicitPassword: explicitPassword) } private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { @@ -111,31 +115,34 @@ extension CMUXCLI { return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) } - private func execInteractiveHelper( + private func runInteractiveHelper( executablePath: String, arguments: [String], environment: [String: String] - ) throws -> Never { - var argv = ([executablePath] + arguments).map { strdup($0) } - defer { - for item in argv { - free(item) - } + ) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + do { + try process.run() + } catch { + throw CLIError(message: "Failed to launch interactive theme picker: \(error.localizedDescription)") } - argv.append(nil) - var envp = environment - .map { key, value in strdup("\(key)=\(value)") } - defer { - for item in envp { - free(item) - } + process.waitUntilExit() + if process.terminationReason == .exit, process.terminationStatus == 0 { + return } - envp.append(nil) - execve(executablePath, &argv, &envp) - let code = errno - throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))") + if process.terminationReason == .uncaughtSignal { + throw CLIError(message: "Interactive theme picker exited from signal \(process.terminationStatus)") + } + throw CLIError(message: "Interactive theme picker exited with status \(process.terminationStatus)") } private func bundledGhosttyResourcesURL() -> URL? { @@ -177,7 +184,7 @@ extension CMUXCLI { ) throws { if commandArgs.isEmpty { if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { - try runInteractiveThemes() + try runInteractiveThemes(socketPath: socketPath, explicitPassword: explicitPassword) return } try printThemesList(jsonOutput: jsonOutput) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2ec80e0cb226..dbda7bea9f71 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14919,6 +14919,16 @@ private extension AppDelegate { } @objc func handleThemesReloadNotification(_ notification: Notification) { + let targetBundleIdentifier = + notification.userInfo?["bundleIdentifier"] as? String + ?? notification.object as? String + if let targetBundleIdentifier, + let bundleIdentifier = Bundle.main.bundleIdentifier, + !targetBundleIdentifier.isEmpty, + targetBundleIdentifier != bundleIdentifier { + return + } + DispatchQueue.main.async { GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") } From 92c0737c52bca270ba4fc2090267e7e222897b76 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:55:13 -0700 Subject: [PATCH 12/39] test: cover interactive theme picker foregrounding --- .../CMUXCLIErrorOutputRegressionTests.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 7cd2408f91af..46a486823abb 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -205,8 +205,24 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { .deletingLastPathComponent() .appendingPathComponent("ghostty", isDirectory: false) try """ - #!/bin/sh - exit 0 + #!/usr/bin/env python3 + import os + import sys + import time + + deadline = time.time() + 2.0 + last_error = "" + while time.time() < deadline: + try: + if os.isatty(0) and os.tcgetpgrp(0) == os.getpgrp(): + sys.exit(0) + last_error = f"pgrp={os.getpgrp()} tpgid={os.tcgetpgrp(0)}" + except OSError as error: + last_error = str(error) + time.sleep(0.02) + + sys.stderr.write(f"theme picker was not foregrounded: {last_error}\\n") + sys.exit(42) """.write(to: fakeGhosttyHelperURL, atomically: true, encoding: .utf8) try fileManager.setAttributes( [.posixPermissions: 0o755], From b0f58e4761a6bce30b6a6c1abc3916134e16b234 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:55:40 -0700 Subject: [PATCH 13/39] fix: foreground interactive theme picker --- CLI/CMUXCLI+Themes.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index 076ee482c5d4..8995f2407247 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -128,11 +128,31 @@ extension CMUXCLI { process.standardOutput = FileHandle.standardOutput process.standardError = FileHandle.standardError + let originalForegroundProcessGroup = isatty(STDIN_FILENO) == 1 ? tcgetpgrp(STDIN_FILENO) : -1 + var didForegroundChild = false do { try process.run() } catch { throw CLIError(message: "Failed to launch interactive theme picker: \(error.localizedDescription)") } + if originalForegroundProcessGroup > 0 { + let childProcessGroup = getpgid(process.processIdentifier) + if childProcessGroup > 0 && childProcessGroup != originalForegroundProcessGroup { + do { + try setInteractiveThemePickerForegroundProcessGroup(childProcessGroup) + _ = Darwin.kill(-childProcessGroup, SIGCONT) + didForegroundChild = true + } catch { + process.terminate() + throw error + } + } + } + defer { + if didForegroundChild { + try? setInteractiveThemePickerForegroundProcessGroup(originalForegroundProcessGroup) + } + } process.waitUntilExit() if process.terminationReason == .exit, process.terminationStatus == 0 { @@ -145,6 +165,14 @@ extension CMUXCLI { throw CLIError(message: "Interactive theme picker exited with status \(process.terminationStatus)") } + private func setInteractiveThemePickerForegroundProcessGroup(_ processGroup: pid_t) throws { + let previousHandler = signal(SIGTTOU, SIG_IGN) + defer { _ = signal(SIGTTOU, previousHandler) } + guard tcsetpgrp(STDIN_FILENO, processGroup) == 0 else { + throw CLIError(message: "Interactive theme picker failed to enter foreground: \(String(cString: strerror(errno)))") + } + } + private func bundledGhosttyResourcesURL() -> URL? { let fileManager = FileManager.default guard let executableURL = resolvedExecutableURL() else { return nil } From 415c1a080da8fbe0b5649ad507c678445fd2f909 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 02:56:26 -0700 Subject: [PATCH 14/39] fix: preserve theme picker launch diagnostics --- CLI/CMUXCLI+Themes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index 8995f2407247..a89b55c5faf8 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -133,7 +133,7 @@ extension CMUXCLI { do { try process.run() } catch { - throw CLIError(message: "Failed to launch interactive theme picker: \(error.localizedDescription)") + throw CLIError(message: "Failed to launch interactive theme picker: \(String(describing: error))") } if originalForegroundProcessGroup > 0 { let childProcessGroup = getpgid(process.processIdentifier) From 7c38f921603e6dffbc5d53b5aa0559e961321ba0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:19:28 -0700 Subject: [PATCH 15/39] test: cover inherited cmux socket context --- cmuxTests/GhosttyConfigTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 4cbbf1c9cd8e..bfac525e8a2e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2387,6 +2387,19 @@ final class SocketControlSettingsTests: XCTestCase { XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock") } + func testTaggedDebugBundleIgnoresSocketOverrideInheritedFromDifferentCmuxBundle() { + let path = SocketControlSettings.socketPath( + environment: [ + "CMUX_BUNDLE_ID": "com.cmuxterm.app.nightly", + "CMUX_SOCKET_PATH": "/tmp/cmux-nightly.sock", + ], + bundleIdentifier: "com.cmuxterm.app.debug.issue.4355.cmux.themes.set.state.dependent", + isDebugBuild: true + ) + + XCTAssertEqual(path, "/tmp/cmux-debug-issue-4355-cmux-themes-set-state-dependent.sock") + } + func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() { let path = SocketControlSettings.socketPath( environment: [ From b361e9a2fc04902864dda8197d82000d3330d3bb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:19:45 -0700 Subject: [PATCH 16/39] fix: ignore inherited socket context from other cmux bundles --- Sources/SocketControlSettings.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 2e93c4f373f9..38ac50f61f87 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -495,12 +495,32 @@ struct SocketControlSettings { if isTruthy(environment[allowSocketPathOverrideKey]) { return true } + if inheritedBundleIdentifierConflicts(environment: environment, bundleIdentifier: bundleIdentifier) { + return false + } if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) { return true } return isDebugBuild } + private static func inheritedBundleIdentifierConflicts( + environment: [String: String], + bundleIdentifier: String? + ) -> Bool { + guard let inheritedBundleIdentifier = normalizedBundleIdentifier(environment["CMUX_BUNDLE_ID"]), + let bundleIdentifier = normalizedBundleIdentifier(bundleIdentifier) else { + return false + } + return inheritedBundleIdentifier != bundleIdentifier + } + + private static func normalizedBundleIdentifier(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.debug" From 6f00d7463f4f5cf91d45c97ca549a9a98603274e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:33:26 -0700 Subject: [PATCH 17/39] fix: derive chrome contrast from terminal themes --- Sources/ContentView.swift | 87 +++++++++++++------ Sources/Panels/TerminalPanelView.swift | 12 ++- .../Sidebar/SidebarAppearanceSupport.swift | 74 +++++++++++++++- .../Windowing/WindowAppearanceSnapshot.swift | 28 ++++++ cmuxTests/TerminalAndGhosttyTests.swift | 22 +++++ cmuxTests/WindowAppearanceSnapshotTests.swift | 42 ++++++++- cmuxTests/WorkspaceUnitTests.swift | 22 ++++- 7 files changed, 251 insertions(+), 36 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b42ff91c488f..41d0fb19492c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2137,6 +2137,7 @@ struct ContentView: View { ZStack(alignment: alignment) { sidebarBackdropLayer(width: width, role: role, appearance: appearance) content() + .environment(\.colorScheme, appearance.chromeColorScheme) } .frame(width: width) } @@ -12422,6 +12423,20 @@ private struct TabItemView: View, Equatable { settings.notificationBadgeColorHex } + private var selectedWorkspaceBackgroundNSColor: NSColor { + sidebarSelectedWorkspaceBackgroundNSColor( + for: colorScheme, + sidebarSelectionColorHex: sidebarSelectionColorHex + ) + } + + private func selectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { + sidebarSelectedWorkspaceForegroundNSColor( + on: selectedWorkspaceBackgroundNSColor, + opacity: opacity + ) + } + private var openSidebarPullRequestLinksInCmuxBrowser: Bool { settings.openPullRequestLinksInCmuxBrowser } @@ -12463,13 +12478,13 @@ private struct TabItemView: View, Equatable { private var activePrimaryTextColor: Color { usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + ? Color(nsColor: selectedWorkspaceForegroundNSColor(opacity: 1.0)) : .primary } private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + ? Color(nsColor: selectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) : .secondary } @@ -12477,15 +12492,19 @@ private struct TabItemView: View, Equatable { if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { return Color(nsColor: nsColor) } - return usesInvertedActiveForeground ? Color.white.opacity(0.25) : cmuxAccentColor() + return usesInvertedActiveForeground ? activePrimaryTextColor.opacity(0.25) : cmuxAccentColor() + } + + private var activeUnreadBadgeTextColor: Color { + usesInvertedActiveForeground ? activePrimaryTextColor : .white } private var activeProgressTrackColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + usesInvertedActiveForeground ? activeSecondaryColor(0.15) : Color.secondary.opacity(0.2) } private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.8) : cmuxAccentColor() + usesInvertedActiveForeground ? activeSecondaryColor(0.8) : cmuxAccentColor() } private var shortcutHintEmphasis: Double { @@ -12633,7 +12652,7 @@ private struct TabItemView: View, Equatable { .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.white) + .foregroundColor(activeUnreadBadgeTextColor) } .frame(width: 16, height: 16) } @@ -12657,7 +12676,8 @@ private struct TabItemView: View, Equatable { if let description = workspaceSnapshot.customDescription { SidebarWorkspaceDescriptionText( markdown: description, - isActive: usesInvertedActiveForeground + isActive: usesInvertedActiveForeground, + activeForegroundColor: activeSecondaryColor(0.84) ) .id(description) } @@ -12680,6 +12700,8 @@ private struct TabItemView: View, Equatable { SidebarMetadataRows( entries: metadataEntries, isActive: usesInvertedActiveForeground, + activeForegroundColor: activeSecondaryColor(0.95), + activeSecondaryForegroundColor: activeSecondaryColor(0.65), onFocus: { updateSelection() } ) .transition(.opacity.combined(with: .move(edge: .top))) @@ -12688,6 +12710,8 @@ private struct TabItemView: View, Equatable { SidebarMetadataMarkdownBlocks( blocks: metadataBlocks, isActive: usesInvertedActiveForeground, + activeForegroundColor: activeSecondaryColor(0.8), + activeSecondaryForegroundColor: activeSecondaryColor(0.65), onFocus: { updateSelection() } ) .transition(.opacity.combined(with: .move(edge: .top))) @@ -13767,7 +13791,7 @@ private struct TabItemView: View, Equatable { } private var pullRequestForegroundColor: Color { - isActive ? .white.opacity(0.75) : .secondary + isActive ? activeSecondaryColor(0.75) : .secondary } private func openPullRequestLink(_ url: URL) { @@ -13825,15 +13849,15 @@ private struct TabItemView: View, Equatable { if isActive { switch level { case .info: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) + return activeSecondaryColor(0.5) case .progress: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) + return activeSecondaryColor(0.8) case .success: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + return activeSecondaryColor(0.9) case .warning: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + return activeSecondaryColor(0.9) case .error: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + return activeSecondaryColor(0.9) } } switch level { @@ -14042,6 +14066,7 @@ private struct TabItemView: View, Equatable { private struct SidebarWorkspaceDescriptionText: View { let markdown: String let isActive: Bool + let activeForegroundColor: Color var body: some View { let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) @@ -14088,7 +14113,7 @@ private struct SidebarWorkspaceDescriptionText: View { } private var foregroundColor: Color { - isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) + isActive ? activeForegroundColor : .secondary.opacity(0.95) } private func accessibilityText(renderedMarkdown: AttributedString?) -> String { @@ -14111,6 +14136,8 @@ enum SidebarMarkdownRenderer { private struct SidebarMetadataRows: View { let entries: [SidebarStatusEntry] let isActive: Bool + let activeForegroundColor: Color + let activeSecondaryForegroundColor: Color let onFocus: () -> Void @State private var isExpanded: Bool = false @@ -14119,7 +14146,12 @@ private struct SidebarMetadataRows: View { var body: some View { VStack(alignment: .leading, spacing: 2) { ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + SidebarMetadataEntryRow( + entry: entry, + isActive: isActive, + activeForegroundColor: activeForegroundColor, + onFocus: onFocus + ) } if shouldShowToggle { @@ -14131,17 +14163,13 @@ private struct SidebarMetadataRows: View { } .buttonStyle(.plain) .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) + .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } } .safeHelp(helpText) } - private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) - } - private var visibleEntries: [SidebarStatusEntry] { guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } return Array(entries.prefix(collapsedEntryLimit)) @@ -14163,6 +14191,7 @@ private struct SidebarMetadataRows: View { private struct SidebarMetadataEntryRow: View { let entry: SidebarStatusEntry let isActive: Bool + let activeForegroundColor: Color let onFocus: () -> Void var body: some View { @@ -14204,12 +14233,12 @@ private struct SidebarMetadataEntryRow: View { if isActive, let raw = entry.color, Color(hex: raw) != nil { - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) + return activeForegroundColor } if let raw = entry.color, let explicit = Color(hex: raw) { return explicit } - return isActive ? .white.opacity(0.8) : .secondary + return isActive ? activeForegroundColor.opacity(0.84) : .secondary } private var iconView: AnyView? { @@ -14260,6 +14289,8 @@ private struct SidebarMetadataEntryRow: View { private struct SidebarMetadataMarkdownBlocks: View { let blocks: [SidebarMetadataBlock] let isActive: Bool + let activeForegroundColor: Color + let activeSecondaryForegroundColor: Color let onFocus: () -> Void @State private var isExpanded: Bool = false @@ -14271,6 +14302,7 @@ private struct SidebarMetadataMarkdownBlocks: View { SidebarMetadataMarkdownBlockRow( block: block, isActive: isActive, + activeForegroundColor: activeForegroundColor, onFocus: onFocus ) } @@ -14284,7 +14316,7 @@ private struct SidebarMetadataMarkdownBlocks: View { } .buttonStyle(.plain) .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -14303,6 +14335,7 @@ private struct SidebarMetadataMarkdownBlocks: View { private struct SidebarMetadataMarkdownBlockRow: View { let block: SidebarMetadataBlock let isActive: Bool + let activeForegroundColor: Color let onFocus: () -> Void @State private var renderedMarkdown: AttributedString? @@ -14329,7 +14362,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { } private var foregroundColor: Color { - isActive ? .white.opacity(0.8) : .secondary + isActive ? activeForegroundColor : .secondary } private func renderMarkdown() { @@ -15101,7 +15134,11 @@ private struct WindowBackdropLayer: View { } } case .clear: - Color.clear + if let sidebarOverlay = snapshot.sidebarContrastOverlayColor(for: role) { + Color(nsColor: sidebarOverlay) + } else { + Color.clear + } } } } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index d0b69cbd291f..7cddb3773ec2 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -65,12 +65,16 @@ struct PanelAppearance { } static func fromConfig(_ config: GhosttyConfig, usesTransparentWindow: Bool) -> PanelAppearance { + let backgroundColor = GhosttyBackgroundTheme.color( + backgroundColor: config.backgroundColor, + opacity: config.backgroundOpacity + ) PanelAppearance( - backgroundColor: GhosttyBackgroundTheme.color( - backgroundColor: config.backgroundColor, - opacity: config.backgroundOpacity + backgroundColor: backgroundColor, + foregroundColor: cmuxReadableForegroundNSColor( + preferred: config.foregroundColor, + on: backgroundColor ), - foregroundColor: config.foregroundColor, dividerColor: Color(nsColor: config.resolvedSplitDividerColor), unfocusedOverlayNSColor: config.unfocusedSplitOverlayFill, unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity, diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 1cb7b0942e9f..2d2d32cb7f1f 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -75,6 +75,70 @@ func cmuxAccentColor() -> Color { Color(nsColor: cmuxAccentNSColor()) } +func cmuxReadableColorScheme(for backgroundColor: NSColor) -> ColorScheme { + let backgroundLuminance = cmuxRelativeLuminance(backgroundColor) + let whiteContrast = cmuxContrastRatio(backgroundLuminance, 1.0) + let blackContrast = cmuxContrastRatio(backgroundLuminance, 0.0) + return whiteContrast >= blackContrast ? .dark : .light +} + +func cmuxReadableForegroundNSColor(on backgroundColor: NSColor, opacity: CGFloat) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + return cmuxReadableForegroundBaseColor(on: backgroundColor) + .withAlphaComponent(clampedOpacity) +} + +func cmuxReadableForegroundNSColor( + preferred preferredColor: NSColor, + on backgroundColor: NSColor, + minimumContrast: CGFloat = 4.5 +) -> NSColor { + guard cmuxContrastRatio(foreground: preferredColor, background: backgroundColor) < minimumContrast else { + return preferredColor + } + return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: preferredColor.alphaComponent) +} + +func cmuxContrastRatio(foreground: NSColor, background: NSColor) -> CGFloat { + cmuxContrastRatio( + cmuxRelativeLuminance(foreground), + cmuxRelativeLuminance(background) + ) +} + +private func cmuxReadableForegroundBaseColor(on backgroundColor: NSColor) -> NSColor { + let backgroundLuminance = cmuxRelativeLuminance(backgroundColor) + let whiteContrast = cmuxContrastRatio(backgroundLuminance, 1.0) + let blackContrast = cmuxContrastRatio(backgroundLuminance, 0.0) + return whiteContrast >= blackContrast ? .white : .black +} + +private func cmuxRelativeLuminance(_ color: NSColor) -> CGFloat { + let srgb = color.usingColorSpace(.sRGB) ?? color + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + _ = alpha + + func linearized(_ component: CGFloat) -> CGFloat { + component <= 0.03928 + ? component / 12.92 + : CGFloat(pow(Double((component + 0.055) / 1.055), 2.4)) + } + + return 0.2126 * linearized(red) + + 0.7152 * linearized(green) + + 0.0722 * linearized(blue) +} + +private func cmuxContrastRatio(_ lhs: CGFloat, _ rhs: CGFloat) -> CGFloat { + let lighter = max(lhs, rhs) + let darker = min(lhs, rhs) + return (lighter + 0.05) / (darker + 0.05) +} + struct SidebarRemoteErrorCopyEntry: Equatable { let workspaceTitle: String let target: String @@ -124,8 +188,14 @@ func sidebarSelectedWorkspaceBackgroundNSColor( } func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { - let clampedOpacity = max(0, min(opacity, 1)) - return NSColor.white.withAlphaComponent(clampedOpacity) + sidebarSelectedWorkspaceForegroundNSColor( + on: sidebarSelectedWorkspaceBackgroundNSColor(for: .dark), + opacity: opacity + ) +} + +func sidebarSelectedWorkspaceForegroundNSColor(on backgroundColor: NSColor, opacity: CGFloat) -> NSColor { + cmuxReadableForegroundNSColor(on: backgroundColor, opacity: opacity) } struct SidebarWorkspaceRowBackgroundStyle { diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index 2b809560fc92..d3ff2e4600f5 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -289,6 +289,34 @@ struct WindowAppearanceSnapshot { terminalBackgroundColor.withAlphaComponent(terminalBackgroundOpacity) } + var chromeColorScheme: ColorScheme { + cmuxReadableColorScheme(for: compositedTerminalBackgroundColor) + } + + func sidebarContrastOverlayColor(for role: WindowBackdropRole) -> NSColor? { + guard unifySurfaceBackdrops, + role == .leftSidebar || role == .rightSidebar else { + return nil + } + + let srgb = terminalBackgroundColor.usingColorSpace(.sRGB) ?? terminalBackgroundColor + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + _ = alpha + + let isLight = terminalBackgroundColor.isLightColor + let adjustment: CGFloat = isLight ? -0.05 : 0.07 + return NSColor( + srgbRed: min(1, max(0, red + adjustment)), + green: min(1, max(0, green + adjustment)), + blue: min(1, max(0, blue + adjustment)), + alpha: isLight ? 0.20 : 0.18 + ) + } + func policy(for role: WindowBackdropRole) -> WindowBackdropPolicy { switch role { case .windowRoot: diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 43aca2bacd3d..6aeb9f00e756 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1843,6 +1843,28 @@ final class PanelAppearanceBackgroundTests: XCTestCase { XCTAssertEqual(appearance.contentBackgroundColor.alphaComponent, 1.0, accuracy: 0.0001) } + func testLowContrastPanelForegroundFallsBackToReadableColor() { + var config = GhosttyConfig() + config.backgroundColor = NSColor(hex: "#FFFFFF")! + config.backgroundOpacity = 1.0 + config.foregroundColor = NSColor(hex: "#FFFFFF")! + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertEqual(appearance.foregroundColor.hexString(), "#000000") + } + + func testReadablePanelForegroundPreservesThemeColor() { + var config = GhosttyConfig() + config.backgroundColor = NSColor(hex: "#000000")! + config.backgroundOpacity = 1.0 + config.foregroundColor = NSColor(hex: "#FDF6E3")! + + let appearance = PanelAppearance.fromConfig(config, usesTransparentWindow: false) + + XCTAssertEqual(appearance.foregroundColor.hexString(), "#FDF6E3") + } + func testGhosttyGlassBackgroundUsesClearContentBackground() { var config = GhosttyConfig() config.backgroundOpacity = 1.0 diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 0396108065ec..5ddccc2ed5b4 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -101,6 +101,41 @@ final class WindowAppearanceSnapshotTests: XCTestCase { ) } + func testChromeColorSchemeFollowsTerminalBackground() { + XCTAssertEqual( + makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#F8F8F2").chromeColorScheme, + .light + ) + XCTAssertEqual( + makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#101820").chromeColorScheme, + .dark + ) + } + + func testUnifiedSidebarContrastOverlaySeparatesLightTerminalBackground() { + let snapshot = makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#FFFFFF") + guard let overlay = snapshot.sidebarContrastOverlayColor(for: .leftSidebar) else { + XCTFail("expected sidebar overlay") + return + } + + XCTAssertLessThan(overlay.luminance, (NSColor(hex: "#FFFFFF") ?? .white).luminance) + XCTAssertEqual(overlay.alphaComponent, 0.20, accuracy: 0.0001) + XCTAssertNil(snapshot.sidebarContrastOverlayColor(for: .titlebar)) + } + + func testUnifiedSidebarContrastOverlaySeparatesDarkTerminalBackground() { + let snapshot = makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#000000") + guard let overlay = snapshot.sidebarContrastOverlayColor(for: .rightSidebar) else { + XCTFail("expected sidebar overlay") + return + } + + XCTAssertGreaterThan(overlay.luminance, (NSColor(hex: "#000000") ?? .black).luminance) + XCTAssertEqual(overlay.alphaComponent, 0.18, accuracy: 0.0001) + XCTAssertNil(makeSnapshot(unifySurfaceBackdrops: false).sidebarContrastOverlayColor(for: .leftSidebar)) + } + func testOpaqueTerminalUsesOpaqueWindowFill() { let snapshot = makeSnapshot(unifySurfaceBackdrops: false, backgroundOpacity: 1.0) let plan = snapshot.backdropPlan(glassEffectAvailable: false) @@ -126,6 +161,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { private func makeSnapshot( unifySurfaceBackdrops: Bool, + backgroundHex: String = "#272822", backgroundOpacity: CGFloat = 0.6, backgroundBlur: GhosttyBackgroundBlur = .disabled, sidebarBlendMode: String = SidebarBlendModeOption.withinWindow.rawValue, @@ -133,8 +169,9 @@ final class WindowAppearanceSnapshotTests: XCTestCase { sidebarTintOpacity: Double = 0.18, bgGlassEnabled: Bool = false ) -> WindowAppearanceSnapshot { + let backgroundColor = NSColor(hex: backgroundHex) ?? .black WindowAppearanceSnapshot( - terminalBackgroundColor: NSColor(hex: "#272822") ?? .black, + terminalBackgroundColor: backgroundColor, terminalBackgroundOpacity: backgroundOpacity, terminalBackgroundBlur: backgroundBlur, terminalRenderingMode: .windowHostBackdrop, @@ -157,8 +194,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { tintHex: "#000000", tintOpacity: 0.03, terminalBackgroundBlur: backgroundBlur, - terminalGlassTintColor: (NSColor(hex: "#272822") ?? .black) - .withAlphaComponent(backgroundOpacity) + terminalGlassTintColor: backgroundColor.withAlphaComponent(backgroundOpacity) ) ) } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5b1d0c2af7c9..034e8d8d3011 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -48,8 +48,26 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) } - func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() { - guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else { + func testSelectedWorkspaceForegroundUsesBlackOnLightSelectionBackground() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor( + on: NSColor(hex: "#FFFFFF")!, + opacity: 0.65 + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } + + func testSelectedWorkspaceForegroundUsesWhiteOnDarkSelectionBackground() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor( + on: NSColor(hex: "#123456")!, + opacity: 0.65 + ).usingColorSpace(.sRGB) else { XCTFail("Expected sRGB-convertible color") return } From e2b92eb29fe79c6d324f2ddae961116966cd7ec8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:35:50 -0700 Subject: [PATCH 18/39] fix: treat theme picker interrupt as cancel --- CLI/CMUXCLI+Themes.swift | 27 +++++++-- .../CMUXCLIErrorOutputRegressionTests.swift | 60 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index a89b55c5faf8..903cc3272d8c 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -25,6 +25,11 @@ extension CMUXCLI { case dark } + private enum InteractiveHelperResult: Equatable { + case completed + case cancelled + } + private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool { guard !jsonOutput else { return false } return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 @@ -54,11 +59,12 @@ extension CMUXCLI { environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path } - try runInteractiveHelper( + let result = try runInteractiveHelper( executablePath: helperURL.path, arguments: ["+list-themes"], environment: environment ) + guard result == .completed else { return } _ = reloadThemesIfPossible(socketPath: socketPath, explicitPassword: explicitPassword) } @@ -119,7 +125,7 @@ extension CMUXCLI { executablePath: String, arguments: [String], environment: [String: String] - ) throws { + ) throws -> InteractiveHelperResult { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments @@ -156,15 +162,28 @@ extension CMUXCLI { process.waitUntilExit() if process.terminationReason == .exit, process.terminationStatus == 0 { - return + return .completed } - if process.terminationReason == .uncaughtSignal { + if isInteractiveThemePickerCancellation(process) { + return .cancelled + } else if process.terminationReason == .uncaughtSignal { throw CLIError(message: "Interactive theme picker exited from signal \(process.terminationStatus)") } throw CLIError(message: "Interactive theme picker exited with status \(process.terminationStatus)") } + private func isInteractiveThemePickerCancellation(_ process: Process) -> Bool { + switch process.terminationReason { + case .uncaughtSignal: + return process.terminationStatus == SIGINT || process.terminationStatus == SIGTERM + case .exit: + return process.terminationStatus == 130 || process.terminationStatus == 143 + @unknown default: + return false + } + } + private func setInteractiveThemePickerForegroundProcessGroup(_ processGroup: pid_t) throws { let previousHandler = signal(SIGTTOU, SIG_IGN) defer { _ = signal(SIGTTOU, previousHandler) } diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 46a486823abb..628dc0538da7 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -254,6 +254,66 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { XCTAssertEqual(responder.receivedRequests, ["reload_config"]) } + func testBareInteractiveThemesTreatsSigintAsSilentCancel() throws { + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-themes-picker-cancel-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let fakeCLIPath = try fakeTaggedBundledCLIPath( + sourceCLIPath: cliPath, + tagSlug: "theme-picker-cancel-\(UUID().uuidString.lowercased())" + ) + let fakeGhosttyHelperURL = URL(fileURLWithPath: fakeCLIPath) + .deletingLastPathComponent() + .appendingPathComponent("ghostty", isDirectory: false) + try """ + #!/usr/bin/env python3 + import os + import signal + import time + + deadline = time.time() + 2.0 + while time.time() < deadline: + if os.isatty(0) and os.tcgetpgrp(0) == os.getpgrp(): + signal.signal(signal.SIGINT, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGINT) + time.sleep(0.02) + sys.exit(42) + """.write(to: fakeGhosttyHelperURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: fakeGhosttyHelperURL.path + ) + + let socketPath = "/tmp/cmux-theme-picker-cancel-\(UUID().uuidString.prefix(8)).sock" + let responder = try UnixSocketResponder(path: socketPath, response: "OK") + defer { responder.stop() } + + let command = [ + "env", + "-i", + "HOME=\(shellSingleQuote(root.path))", + "CFFIXED_USER_HOME=\(shellSingleQuote(root.path))", + "CMUX_SOCKET_PATH=\(shellSingleQuote(socketPath))", + "CMUX_CLI_SENTRY_DISABLED=1", + "PATH=/usr/bin:/bin", + "/usr/bin/script", + "-q", + "/dev/null", + shellSingleQuote(fakeCLIPath), + "themes", + ].joined(separator: " ") + let result = runShell(command, timeout: 5) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + XCTAssertFalse(result.stdout.contains("Interactive theme picker exited"), result.stdout) + XCTAssertEqual(responder.receivedRequests, []) + } + func testBrowserDownloadWaitUsesRequestedTimeoutForSocketResponse() throws { let cliPath = try bundledCLIPath() let socketPath = "/tmp/cmux-dw-\(UUID().uuidString.prefix(8)).sock" From 825ddfc0998ae086d9088e176ce2b5a928bbcb9a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:39:41 -0700 Subject: [PATCH 19/39] fix: return panel appearance config --- Sources/Panels/TerminalPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 7cddb3773ec2..0cab106980b1 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -69,7 +69,7 @@ struct PanelAppearance { backgroundColor: config.backgroundColor, opacity: config.backgroundOpacity ) - PanelAppearance( + return PanelAppearance( backgroundColor: backgroundColor, foregroundColor: cmuxReadableForegroundNSColor( preferred: config.foregroundColor, From 098f38edf13565c3d4bcb004a155fd76b5bdf7ca Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:47:52 -0700 Subject: [PATCH 20/39] test: fix theme picker cancel fallback helper --- cmuxTests/CMUXCLIErrorOutputRegressionTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index 628dc0538da7..f6186c27a486 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -273,6 +273,7 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { #!/usr/bin/env python3 import os import signal + import sys import time deadline = time.time() + 2.0 From 6fedbb875f30a0fe0da9c98ba61f9749e3a41ad3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 03:58:23 -0700 Subject: [PATCH 21/39] test: return window appearance fixture --- cmuxTests/WindowAppearanceSnapshotTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 5ddccc2ed5b4..84ea8be14c17 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -170,7 +170,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { bgGlassEnabled: Bool = false ) -> WindowAppearanceSnapshot { let backgroundColor = NSColor(hex: backgroundHex) ?? .black - WindowAppearanceSnapshot( + return WindowAppearanceSnapshot( terminalBackgroundColor: backgroundColor, terminalBackgroundOpacity: backgroundOpacity, terminalBackgroundBlur: backgroundBlur, From 4223df74efe9bf0292c0b1e420950fdcb40c4e12 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 04:01:30 -0700 Subject: [PATCH 22/39] fix: keep sidebar chrome readable across themes --- Sources/ContentView.swift | 6 ++++-- Sources/Sidebar/SidebarAppearanceSupport.swift | 12 ++++++++++-- Sources/Windowing/WindowAppearanceSnapshot.swift | 4 ++++ cmuxTests/WindowAppearanceSnapshotTests.swift | 16 +++++++++++++++- cmuxTests/WorkspaceUnitTests.swift | 16 ++++++++++++++++ 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 41d0fb19492c..47d68171bb46 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2137,7 +2137,7 @@ struct ContentView: View { ZStack(alignment: alignment) { sidebarBackdropLayer(width: width, role: role, appearance: appearance) content() - .environment(\.colorScheme, appearance.chromeColorScheme) + .environment(\.colorScheme, appearance.sidebarContentColorScheme) } .frame(width: width) } @@ -2607,6 +2607,7 @@ struct ContentView: View { .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible && !isMinimalMode { fullscreenControls + .environment(\.colorScheme, appearance.sidebarContentColorScheme) .padding(.leading, 10) .padding(.top, 4) } @@ -12433,7 +12434,8 @@ private struct TabItemView: View, Equatable { private func selectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { sidebarSelectedWorkspaceForegroundNSColor( on: selectedWorkspaceBackgroundNSColor, - opacity: opacity + opacity: opacity, + usesDefaultSelectionColor: sidebarSelectionColorHex == nil ) } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 2d2d32cb7f1f..1c0347021e16 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -190,11 +190,19 @@ func sidebarSelectedWorkspaceBackgroundNSColor( func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { sidebarSelectedWorkspaceForegroundNSColor( on: sidebarSelectedWorkspaceBackgroundNSColor(for: .dark), - opacity: opacity + opacity: opacity, + usesDefaultSelectionColor: true ) } -func sidebarSelectedWorkspaceForegroundNSColor(on backgroundColor: NSColor, opacity: CGFloat) -> NSColor { +func sidebarSelectedWorkspaceForegroundNSColor( + on backgroundColor: NSColor, + opacity: CGFloat, + usesDefaultSelectionColor: Bool = false +) -> NSColor { + if usesDefaultSelectionColor { + return NSColor.white.withAlphaComponent(max(0, min(opacity, 1))) + } cmuxReadableForegroundNSColor(on: backgroundColor, opacity: opacity) } diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index d3ff2e4600f5..b6190cb69c5e 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -293,6 +293,10 @@ struct WindowAppearanceSnapshot { cmuxReadableColorScheme(for: compositedTerminalBackgroundColor) } + var sidebarContentColorScheme: ColorScheme { + unifySurfaceBackdrops ? chromeColorScheme : sidebarSettings.colorScheme + } + func sidebarContrastOverlayColor(for role: WindowBackdropRole) -> NSColor? { guard unifySurfaceBackdrops, role == .leftSidebar || role == .rightSidebar else { diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 84ea8be14c17..44e37e062abe 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -112,6 +112,19 @@ final class WindowAppearanceSnapshotTests: XCTestCase { ) } + func testSidebarContentColorSchemeUsesTerminalOnlyForUnifiedBackdrops() { + XCTAssertEqual( + makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#101820", sidebarColorScheme: .light) + .sidebarContentColorScheme, + .dark + ) + XCTAssertEqual( + makeSnapshot(unifySurfaceBackdrops: false, backgroundHex: "#101820", sidebarColorScheme: .light) + .sidebarContentColorScheme, + .light + ) + } + func testUnifiedSidebarContrastOverlaySeparatesLightTerminalBackground() { let snapshot = makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#FFFFFF") guard let overlay = snapshot.sidebarContrastOverlayColor(for: .leftSidebar) else { @@ -167,6 +180,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { sidebarBlendMode: String = SidebarBlendModeOption.withinWindow.rawValue, sidebarTintHexDark: String? = nil, sidebarTintOpacity: Double = 0.18, + sidebarColorScheme: ColorScheme = .dark, bgGlassEnabled: Bool = false ) -> WindowAppearanceSnapshot { let backgroundColor = NSColor(hex: backgroundHex) ?? .black @@ -186,7 +200,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { tintOpacity: sidebarTintOpacity, cornerRadius: 0, blurOpacity: 1, - colorScheme: .dark + colorScheme: sidebarColorScheme ), windowGlassSettings: WindowGlassSettingsSnapshot( sidebarBlendModeRawValue: sidebarBlendMode, diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 034e8d8d3011..46f31591edce 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -78,6 +78,22 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) } + func testDefaultSelectedWorkspaceForegroundUsesNativeSelectionTextOnAccentBackground() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor( + on: sidebarSelectedWorkspaceBackgroundNSColor(for: .light), + opacity: 0.65, + usesDefaultSelectionColor: true + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } + @MainActor func testSolidFillKeepsSelectedBackgroundForActiveCustomColoredWorkspaceRow() { let manager = TabManager() From d354eca3ce88e1ceada2928933390912cf665b84 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 04:07:29 -0700 Subject: [PATCH 23/39] fix: return selected row foreground color --- Sources/Sidebar/SidebarAppearanceSupport.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 1c0347021e16..b60c6645fc92 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -203,7 +203,7 @@ func sidebarSelectedWorkspaceForegroundNSColor( if usesDefaultSelectionColor { return NSColor.white.withAlphaComponent(max(0, min(opacity, 1))) } - cmuxReadableForegroundNSColor(on: backgroundColor, opacity: opacity) + return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: opacity) } struct SidebarWorkspaceRowBackgroundStyle { From b6d34706683f6f4bc5aec8a4054939c6465a854b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 04:24:07 -0700 Subject: [PATCH 24/39] fix: align terminal scheme with live theme --- Sources/GhosttyTerminalView.swift | 22 +++++++++++++---- .../Sidebar/SidebarAppearanceSupport.swift | 24 +++++++++++++++++++ .../Windowing/WindowAppearanceSnapshot.swift | 16 ++++++++++--- cmuxTests/TerminalAndGhosttyTests.swift | 19 +++++++++++++++ cmuxTests/WindowAppearanceSnapshotTests.swift | 10 ++++++++ 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3b373f629a82..ef18951929f0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6738,6 +6738,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } + static func surfaceColorSchemePreference( + appPreferredColorScheme: GhosttyConfig.ColorSchemePreference, + surfaceAppearanceBestMatch _: NSAppearance.Name? + ) -> GhosttyConfig.ColorSchemePreference { + // The terminal config loader already resolves light/dark theme pairs from cmux's + // appearance setting. AppKit effectiveAppearance can lag behind that after live + // theme changes, so using it here can put Ghostty surfaces on the opposite theme. + appPreferredColorScheme + } + // Visibility is used for focus gating. Explicit portal visibility transitions // also drive Ghostty occlusion so hidden workspace/split surfaces pause and // queue a redraw when they become visible again. @@ -7262,14 +7272,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func applySurfaceColorScheme(force: Bool = false) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) - let scheme: ghostty_color_scheme_e = bestMatch == .darkAqua - ? GHOSTTY_COLOR_SCHEME_DARK - : GHOSTTY_COLOR_SCHEME_LIGHT + let preferredColorScheme = Self.surfaceColorSchemePreference( + appPreferredColorScheme: GhosttyConfig.currentColorSchemePreference(), + surfaceAppearanceBestMatch: bestMatch + ) + let scheme = GhosttyApp.ghosttyRuntimeColorScheme(for: preferredColorScheme) if !force, appliedColorScheme == scheme { if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" GhosttyApp.shared.logBackground( - "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=false" + "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") preferred=\(schemeLabel) scheme=\(schemeLabel) force=\(force) applied=false" ) } return @@ -7279,7 +7291,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" GhosttyApp.shared.logBackground( - "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=true" + "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") preferred=\(schemeLabel) scheme=\(schemeLabel) force=\(force) applied=true" ) } } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index b60c6645fc92..63cdd192c21b 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -99,6 +99,30 @@ func cmuxReadableForegroundNSColor( return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: preferredColor.alphaComponent) } +func cmuxCompositedNSColor(_ foreground: NSColor, over background: NSColor) -> NSColor { + let fg = foreground.usingColorSpace(.sRGB) ?? foreground + let bg = background.usingColorSpace(.sRGB) ?? background + var foregroundRed: CGFloat = 0 + var foregroundGreen: CGFloat = 0 + var foregroundBlue: CGFloat = 0 + var foregroundAlpha: CGFloat = 0 + var backgroundRed: CGFloat = 0 + var backgroundGreen: CGFloat = 0 + var backgroundBlue: CGFloat = 0 + var backgroundAlpha: CGFloat = 0 + fg.getRed(&foregroundRed, green: &foregroundGreen, blue: &foregroundBlue, alpha: &foregroundAlpha) + bg.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: &backgroundAlpha) + _ = backgroundAlpha + + let alpha = max(0, min(foregroundAlpha, 1)) + return NSColor( + srgbRed: foregroundRed * alpha + backgroundRed * (1 - alpha), + green: foregroundGreen * alpha + backgroundGreen * (1 - alpha), + blue: foregroundBlue * alpha + backgroundBlue * (1 - alpha), + alpha: 1 + ) +} + func cmuxContrastRatio(foreground: NSColor, background: NSColor) -> CGFloat { cmuxContrastRatio( cmuxRelativeLuminance(foreground), diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index b6190cb69c5e..3833885a940d 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -275,8 +275,15 @@ struct WindowAppearanceSnapshot { CGFloat(max(0.0, min(1.0, opacity))) } - static func compositedTerminalColor(backgroundColor: NSColor, opacity: Double) -> NSColor { - backgroundColor.withAlphaComponent(clampedOpacity(opacity)) + static func compositedTerminalColor( + backgroundColor: NSColor, + opacity: Double, + over baseColor: NSColor = .windowBackgroundColor + ) -> NSColor { + cmuxCompositedNSColor( + backgroundColor.withAlphaComponent(clampedOpacity(opacity)), + over: baseColor + ) } static func terminalRenderingMode( @@ -286,7 +293,10 @@ struct WindowAppearanceSnapshot { } var compositedTerminalBackgroundColor: NSColor { - terminalBackgroundColor.withAlphaComponent(terminalBackgroundOpacity) + Self.compositedTerminalColor( + backgroundColor: terminalBackgroundColor, + opacity: terminalBackgroundOpacity + ) } var chromeColorScheme: ColorScheme { diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 6aeb9f00e756..c27e80bb386b 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1814,6 +1814,25 @@ final class GhosttyBackgroundThemeTests: XCTestCase { } } +final class GhosttySurfaceColorSchemeTests: XCTestCase { + func testSurfaceColorSchemeFollowsAppPreferenceNotEffectiveAppearance() { + XCTAssertEqual( + GhosttyNSView.surfaceColorSchemePreference( + appPreferredColorScheme: .light, + surfaceAppearanceBestMatch: .darkAqua + ), + .light + ) + XCTAssertEqual( + GhosttyNSView.surfaceColorSchemePreference( + appPreferredColorScheme: .dark, + surfaceAppearanceBestMatch: .aqua + ), + .dark + ) + } +} + final class PanelAppearanceBackgroundTests: XCTestCase { func testTransparentGhosttyOpacityUsesClearContentBackground() { var config = GhosttyConfig() diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 44e37e062abe..dec5001c4798 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -112,6 +112,16 @@ final class WindowAppearanceSnapshotTests: XCTestCase { ) } + func testChromeColorSchemeAccountsForTranslucentTerminalBackground() { + let composited = WindowAppearanceSnapshot.compositedTerminalColor( + backgroundColor: NSColor(hex: "#101820")!, + opacity: 0.05, + over: .white + ) + + XCTAssertEqual(cmuxReadableColorScheme(for: composited), .light) + } + func testSidebarContentColorSchemeUsesTerminalOnlyForUnifiedBackdrops() { XCTAssertEqual( makeSnapshot(unifySurfaceBackdrops: true, backgroundHex: "#101820", sidebarColorScheme: .light) From 228f3abdd9d1c8fb93f640c5c202e6d2c0dcd13a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 04:54:26 -0700 Subject: [PATCH 25/39] fix: synchronize theme contrast on reload --- .../GhosttySurfaceConfigurationRefresh.swift | 2 + Sources/AppDelegate.swift | 17 ++++- Sources/GhosttyTerminalView.swift | 62 +++++++++++++----- .../Sidebar/SidebarAppearanceSupport.swift | 24 ++++++- Sources/Update/UpdateTitlebarAccessory.swift | 28 ++++++--- cmuxTests/AppearanceSettingsTests.swift | 7 +++ cmuxTests/SidebarWidthPolicyTests.swift | 63 +++++++++++++++++++ 7 files changed, 177 insertions(+), 26 deletions(-) diff --git a/Sources/App/GhosttySurfaceConfigurationRefresh.swift b/Sources/App/GhosttySurfaceConfigurationRefresh.swift index 3887daf302ba..9424b2b129bb 100644 --- a/Sources/App/GhosttySurfaceConfigurationRefresh.swift +++ b/Sources/App/GhosttySurfaceConfigurationRefresh.swift @@ -6,10 +6,12 @@ enum GhosttySurfaceConfigurationRefresh { to surface: ghostty_surface_t?, source: String, reloadSurfaceConfiguration: (ghostty_surface_t, Bool, String) -> Void, + applySurfaceColorScheme: () -> Void, refreshHostBackground: () -> Void, forceRefresh: (String) -> Void ) { if let surface { + applySurfaceColorScheme() reloadSurfaceConfiguration(surface, true, source) } refreshHostBackground() diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 65f020189ae6..f18a4f5fec64 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5049,7 +5049,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { + func refreshTerminalSurfacesAfterGhosttyConfigReload( + source: String, + preferredColorScheme: GhosttyConfig.ColorSchemePreference + ) { var refreshedCount = 0 forEachTerminalPanel { terminalPanel in let liveSurface = terminalPanel.surface.liveSurfaceForGhosttyAccess( @@ -5059,7 +5062,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent to: liveSurface, source: source, reloadSurfaceConfiguration: { surface, soft, source in - GhosttyApp.shared.reloadSurfaceConfiguration(surface, soft: soft, source: source) + GhosttyApp.shared.reloadSurfaceConfiguration( + surface, + soft: soft, + source: source, + preferredColorScheme: preferredColorScheme + ) + }, + applySurfaceColorScheme: { + terminalPanel.hostedView.reapplySurfaceColorSchemeAfterGhosttyConfigReload( + preferredColorScheme: preferredColorScheme + ) }, refreshHostBackground: { terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ef18951929f0..2fd4e9216862 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3172,6 +3172,7 @@ class GhosttyApp { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return } + synchronizeGhosttyRuntimeColorScheme(reloadColorScheme, source: "reloadConfiguration:\(source)") logThemeAction("reload begin source=\(source) soft=\(soft)") resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { @@ -3179,7 +3180,10 @@ class GhosttyApp { lastAppearanceColorScheme = reloadColorScheme GhosttyConfig.invalidateLoadCache() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) - scheduleSurfaceRefreshAfterConfigurationReload(source: source) + scheduleSurfaceRefreshAfterConfigurationReload( + source: source, + preferredColorScheme: reloadColorScheme + ) logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return } @@ -3209,13 +3213,22 @@ class GhosttyApp { lastAppearanceColorScheme = reloadColorScheme GhosttyConfig.invalidateLoadCache() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) - scheduleSurfaceRefreshAfterConfigurationReload(source: source) + scheduleSurfaceRefreshAfterConfigurationReload( + source: source, + preferredColorScheme: reloadColorScheme + ) logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } - private func scheduleSurfaceRefreshAfterConfigurationReload(source: String) { + private func scheduleSurfaceRefreshAfterConfigurationReload( + source: String, + preferredColorScheme: GhosttyConfig.ColorSchemePreference + ) { DispatchQueue.main.async { - AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload(source: source) + AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload( + source: source, + preferredColorScheme: preferredColorScheme + ) } } @@ -4158,7 +4171,16 @@ class GhosttyApp { "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { - self.reloadSurfaceConfiguration(target.target.surface, soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")") + let preferredColorScheme = GhosttyConfig.currentColorSchemePreference() + surfaceView.terminalSurface?.hostedView.reapplySurfaceColorSchemeAfterGhosttyConfigReload( + preferredColorScheme: preferredColorScheme + ) + self.reloadSurfaceConfiguration( + target.target.surface, + soft: soft, + source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", + preferredColorScheme: preferredColorScheme + ) surfaceView.terminalSurface?.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() surfaceView.terminalSurface?.forceRefresh(reason: "surface.reloadConfig") return true @@ -6748,13 +6770,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { appPreferredColorScheme } - // Visibility is used for focus gating. Explicit portal visibility transitions - // also drive Ghostty occlusion so hidden workspace/split surfaces pause and - // queue a redraw when they become visible again. - fileprivate var isVisibleInUI: Bool { visibleInUI } - fileprivate func setVisibleInUI(_ visible: Bool) { - visibleInUI = visible - } + // Visibility is used for focus gating. Explicit portal visibility transitions + // also drive Ghostty occlusion so hidden workspace/split surfaces pause and + // queue a redraw when they become visible again. + fileprivate var isVisibleInUI: Bool { visibleInUI } + fileprivate func setVisibleInUI(_ visible: Bool) { + visibleInUI = visible + } override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -7269,11 +7291,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface?.surface } - private func applySurfaceColorScheme(force: Bool = false) { + private func applySurfaceColorScheme( + force: Bool = false, + preferredColorScheme: GhosttyConfig.ColorSchemePreference? = nil + ) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) let preferredColorScheme = Self.surfaceColorSchemePreference( - appPreferredColorScheme: GhosttyConfig.currentColorSchemePreference(), + appPreferredColorScheme: preferredColorScheme ?? GhosttyConfig.currentColorSchemePreference(), surfaceAppearanceBestMatch: bestMatch ) let scheme = GhosttyApp.ghosttyRuntimeColorScheme(for: preferredColorScheme) @@ -11612,6 +11637,15 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.applyWindowBackgroundIfActive() } + func reapplySurfaceColorSchemeAfterGhosttyConfigReload( + preferredColorScheme: GhosttyConfig.ColorSchemePreference + ) { + surfaceView.applySurfaceColorScheme( + force: true, + preferredColorScheme: preferredColorScheme + ) + } + private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let localFrame = PaneDropRouting.compactOverlayFrame(for: zone, in: size) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 63cdd192c21b..230344f05a11 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -40,6 +40,16 @@ func sidebarActiveForegroundNSColor( return baseColor.withAlphaComponent(clampedOpacity) } +func titlebarControlForegroundNSColor( + opacity: CGFloat, + appearance: WindowAppearanceSnapshot = .currentFromUserDefaults() +) -> NSColor { + cmuxReadableForegroundNSColor( + on: appearance.compositedTerminalBackgroundColor, + opacity: opacity + ) +} + func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor { switch colorScheme { case .dark: @@ -93,7 +103,10 @@ func cmuxReadableForegroundNSColor( on backgroundColor: NSColor, minimumContrast: CGFloat = 4.5 ) -> NSColor { - guard cmuxContrastRatio(foreground: preferredColor, background: backgroundColor) < minimumContrast else { + let foregroundForComparison = preferredColor.alphaComponent < 1 + ? cmuxCompositedNSColor(preferredColor, over: backgroundColor) + : preferredColor + guard cmuxContrastRatio(foreground: foregroundForComparison, background: backgroundColor) < minimumContrast else { return preferredColor } return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: preferredColor.alphaComponent) @@ -224,10 +237,15 @@ func sidebarSelectedWorkspaceForegroundNSColor( opacity: CGFloat, usesDefaultSelectionColor: Bool = false ) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) if usesDefaultSelectionColor { - return NSColor.white.withAlphaComponent(max(0, min(opacity, 1))) + return cmuxReadableForegroundNSColor( + preferred: NSColor.white.withAlphaComponent(clampedOpacity), + on: backgroundColor, + minimumContrast: 3.0 + ) } - return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: opacity) + return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: clampedOpacity) } struct SidebarWorkspaceRowBackgroundStyle { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index c3f278b82646..9cd7f5e6f370 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -349,6 +349,7 @@ func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) struct TitlebarControlButton: View { let config: TitlebarControlsStyleConfig + let foregroundColor: Color let accessibilityIdentifier: String let accessibilityLabel: String let action: () -> Void @@ -386,7 +387,7 @@ struct TitlebarControlButton: View { private var hoverBackground: some View { if config.hoverBackground && isHovering { RoundedRectangle(cornerRadius: config.buttonCornerRadius, style: .continuous) - .fill(Color.primary.opacity(0.08)) + .fill(foregroundColor.opacity(0.10)) } } } @@ -433,6 +434,7 @@ struct TitlebarControlsView: View { @ObservedObject private var popoverVisibilityState = NotificationsPopoverVisibilityState.shared @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue @State private var shortcutRefreshTick = 0 + @State private var appearanceRefreshTick = 0 @State private var isHoveringControls = false @State private var hostWindowNumber: Int? @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() @@ -485,9 +487,11 @@ struct TitlebarControlsView: View { // Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings. // (The titlebar controls don't otherwise re-render on UserDefaults changes.) let _ = shortcutRefreshTick + let _ = appearanceRefreshTick let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic let config = style.config - controlsGroup(config: config) + let foregroundColor = Color(nsColor: titlebarControlForegroundNSColor(opacity: 0.78)) + controlsGroup(config: config, foregroundColor: foregroundColor) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) .contentShape(Rectangle()) @@ -514,6 +518,12 @@ struct TitlebarControlsView: View { .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in + appearanceRefreshTick &+= 1 + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + appearanceRefreshTick &+= 1 + } .onAppear { modifierKeyMonitor.start() } @@ -533,11 +543,12 @@ struct TitlebarControlsView: View { } @ViewBuilder - private func controlsGroup(config: TitlebarControlsStyleConfig) -> some View { + private func controlsGroup(config: TitlebarControlsStyleConfig, foregroundColor: Color) -> some View { let hintLayoutItems = titlebarHintLayoutItems(config: config) let content = HStack(spacing: config.spacing) { TitlebarControlButton( config: config, + foregroundColor: foregroundColor, accessibilityIdentifier: "titlebarControl.toggleSidebar", accessibilityLabel: String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"), action: { @@ -546,12 +557,13 @@ struct TitlebarControlsView: View { #endif onToggleSidebar() }) { - iconLabel(systemName: "sidebar.left", config: config) + iconLabel(systemName: "sidebar.left", config: config, foregroundColor: foregroundColor) } .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) TitlebarControlButton( config: config, + foregroundColor: foregroundColor, accessibilityIdentifier: "titlebarControl.showNotifications", accessibilityLabel: String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"), action: { @@ -561,7 +573,7 @@ struct TitlebarControlsView: View { onToggleNotifications() }) { ZStack(alignment: .topTrailing) { - iconLabel(systemName: "bell", config: config) + iconLabel(systemName: "bell", config: config, foregroundColor: foregroundColor) if notificationStore.unreadCount > 0 { Text("\(min(notificationStore.unreadCount, 99))") @@ -581,6 +593,7 @@ struct TitlebarControlsView: View { TitlebarControlButton( config: config, + foregroundColor: foregroundColor, accessibilityIdentifier: "titlebarControl.newTab", accessibilityLabel: String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"), action: { @@ -592,7 +605,7 @@ struct TitlebarControlsView: View { rightClickAction: { anchorView, event in _ = AppDelegate.shared?.showNewWorkspaceContextMenu(anchorView: anchorView, event: event) }) { - iconLabel(systemName: "plus", config: config) + iconLabel(systemName: "plus", config: config, foregroundColor: foregroundColor) } .safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace"))) @@ -711,9 +724,10 @@ struct TitlebarControlsView: View { } @ViewBuilder - private func iconLabel(systemName: String, config: TitlebarControlsStyleConfig) -> some View { + private func iconLabel(systemName: String, config: TitlebarControlsStyleConfig, foregroundColor: Color) -> some View { let icon = Image(systemName: systemName) .font(.system(size: config.iconSize, weight: .semibold)) + .foregroundColor(foregroundColor) .frame(width: config.buttonSize, height: config.buttonSize) if config.buttonBackground { diff --git a/cmuxTests/AppearanceSettingsTests.swift b/cmuxTests/AppearanceSettingsTests.swift index ad88334dc68c..d9f22279d6b3 100644 --- a/cmuxTests/AppearanceSettingsTests.swift +++ b/cmuxTests/AppearanceSettingsTests.swift @@ -113,6 +113,9 @@ final class AppearanceSettingsTests: XCTestCase { XCTAssertTrue(soft) events.append("reload:\(source)") }, + applySurfaceColorScheme: { + events.append("color-scheme") + }, refreshHostBackground: { events.append("host-background") }, @@ -122,6 +125,7 @@ final class AppearanceSettingsTests: XCTestCase { ) XCTAssertEqual(events, [ + "color-scheme", "reload:appearanceSync:test", "host-background", "force-refresh:\(GhosttySurfaceConfigurationRefresh.forceRefreshReason)" @@ -137,6 +141,9 @@ final class AppearanceSettingsTests: XCTestCase { reloadSurfaceConfiguration: { _, _, _ in events.append("reload") }, + applySurfaceColorScheme: { + events.append("color-scheme") + }, refreshHostBackground: { events.append("host-background") }, diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index 92c62ffc781f..bc1fee99f0f2 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -128,6 +128,39 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { assertColor(coloredSelected.color, equals: NSColor(hex: selectionHex)) } + func testDefaultSelectedForegroundFallsBackForPaleSelectionBackground() throws { + let background = try XCTUnwrap(NSColor(hex: "#F7F7F7")) + let foreground = sidebarSelectedWorkspaceForegroundNSColor( + on: background, + opacity: 1.0, + usesDefaultSelectionColor: true + ) + + assertColor(foreground, equals: .black) + XCTAssertGreaterThanOrEqual( + cmuxContrastRatio(foreground: foreground, background: background), + 4.5 + ) + } + + func testTitlebarControlForegroundContrastsWithLightTerminalBackground() throws { + let background = try XCTUnwrap(NSColor(hex: "#F7F7F7")) + let snapshot = makeWindowAppearanceSnapshot(background: background) + let foreground = titlebarControlForegroundNSColor( + opacity: 1.0, + appearance: snapshot + ) + + assertColor(foreground, equals: .black) + XCTAssertGreaterThanOrEqual( + cmuxContrastRatio( + foreground: foreground, + background: snapshot.compositedTerminalBackgroundColor + ), + 4.5 + ) + } + private func assertColor( _ actual: NSColor?, equals expected: NSColor?, @@ -148,6 +181,36 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { ) } + private func makeWindowAppearanceSnapshot(background: NSColor) -> WindowAppearanceSnapshot { + WindowAppearanceSnapshot( + terminalBackgroundColor: background, + terminalBackgroundOpacity: 1.0, + terminalBackgroundBlur: .disabled, + terminalRenderingMode: .windowHostBackdrop, + unifySurfaceBackdrops: true, + sidebarSettings: SidebarBackdropSettingsSnapshot( + materialRawValue: SidebarMaterialOption.sidebar.rawValue, + blendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, + stateRawValue: SidebarStateOption.followWindow.rawValue, + tintHex: SidebarTintDefaults.hex, + tintHexLight: nil, + tintHexDark: nil, + tintOpacity: SidebarTintDefaults.opacity, + cornerRadius: 0, + blurOpacity: 1, + colorScheme: .light + ), + windowGlassSettings: WindowGlassSettingsSnapshot( + sidebarBlendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, + isEnabled: false, + tintHex: "#000000", + tintOpacity: 0, + terminalBackgroundBlur: .disabled, + terminalGlassTintColor: background + ) + ) + } + private func colorsAreEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { guard let lhs, let rhs else { return lhs == nil && rhs == nil From 374958aaec2331f00f38122cbc5c4b9913d30665 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 04:59:03 -0700 Subject: [PATCH 26/39] fix: expose surface color scheme reapply bridge --- Sources/GhosttyTerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2fd4e9216862..a89e25d5d054 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7291,7 +7291,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface?.surface } - private func applySurfaceColorScheme( + fileprivate func applySurfaceColorScheme( force: Bool = false, preferredColorScheme: GhosttyConfig.ColorSchemePreference? = nil ) { From f7450b3d494a392b714860faaa9b83378c280f9d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 05:05:24 -0700 Subject: [PATCH 27/39] fix: derive terminal runtime scheme from theme background --- ...hosttyApp+SurfaceConfigurationReload.swift | 2 +- Sources/GhosttyTerminalView.swift | 66 ++++++++++++------- .../Sidebar/SidebarAppearanceSupport.swift | 13 ++-- cmuxTests/GhosttyConfigTests.swift | 15 +++++ cmuxTests/SidebarWidthPolicyTests.swift | 15 +++++ 5 files changed, 80 insertions(+), 31 deletions(-) diff --git a/Sources/GhosttyApp+SurfaceConfigurationReload.swift b/Sources/GhosttyApp+SurfaceConfigurationReload.swift index 740fff57be54..60c01d1367c4 100644 --- a/Sources/GhosttyApp+SurfaceConfigurationReload.swift +++ b/Sources/GhosttyApp+SurfaceConfigurationReload.swift @@ -12,7 +12,7 @@ extension GhosttyApp { } guard let newConfig = ghostty_config_new() else { return } - let reloadColorScheme = preferredColorScheme ?? GhosttyConfig.currentColorSchemePreference() + let reloadColorScheme = preferredColorScheme ?? effectiveTerminalColorSchemePreference _ = loadDefaultConfigFilesWithLegacyFallback( newConfig, preferredColorScheme: reloadColorScheme diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a89e25d5d054..fec16c5e13d5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1696,6 +1696,7 @@ class GhosttyApp { private(set) var defaultCursorTextColor: NSColor = GhosttyApp.fallbackAppearanceConfig.cursorTextColor private(set) var defaultSelectionBackground: NSColor = GhosttyApp.fallbackAppearanceConfig.selectionBackground private(set) var defaultSelectionForeground: NSColor = GhosttyApp.fallbackAppearanceConfig.selectionForeground + private(set) var effectiveTerminalColorSchemePreference: GhosttyConfig.ColorSchemePreference = .dark private(set) var usesHostLayerBackground = false private(set) var userGhosttyShellIntegrationMode: String = "detect" private static func resolveBackgroundLogURL( @@ -2166,7 +2167,7 @@ class GhosttyApp { } // Notify observers that a usable config is available (initial load). - synchronizeGhosttyRuntimeColorScheme(initialColorScheme, source: "initialize") + synchronizeGhosttyRuntimeColorScheme(effectiveTerminalColorSchemePreference, source: "initialize") lastAppearanceColorScheme = initialColorScheme GhosttyConfig.invalidateLoadCache() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) @@ -3037,6 +3038,12 @@ class GhosttyApp { } } + static func terminalRuntimeColorSchemePreference( + forBackgroundColor backgroundColor: NSColor + ) -> GhosttyConfig.ColorSchemePreference { + cmuxReadableColorScheme(for: backgroundColor) == .light ? .light : .dark + } + static func shouldCaptureScrollLagEvent( samples: Int, averageMs: Double, @@ -3172,17 +3179,22 @@ class GhosttyApp { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return } - synchronizeGhosttyRuntimeColorScheme(reloadColorScheme, source: "reloadConfiguration:\(source)") + // Use the appearance preference only while loading config so Ghostty can resolve + // conditional theme pairs. After the config resolves, rendering follows the + // terminal background itself; single explicit light themes can be used in dark app mode. + synchronizeGhosttyRuntimeColorScheme(reloadColorScheme, source: "reloadConfiguration:\(source):load") logThemeAction("reload begin source=\(source) soft=\(soft)") resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { + let effectiveReloadColorScheme = effectiveTerminalColorSchemePreference + synchronizeGhosttyRuntimeColorScheme(effectiveReloadColorScheme, source: "reloadConfiguration:\(source):resolved") ghostty_app_update_config(app, config) lastAppearanceColorScheme = reloadColorScheme GhosttyConfig.invalidateLoadCache() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload( source: source, - preferredColorScheme: reloadColorScheme + preferredColorScheme: effectiveReloadColorScheme ) logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return @@ -3196,13 +3208,15 @@ class GhosttyApp { newConfig, preferredColorScheme: reloadColorScheme ) - ghostty_app_update_config(app, newConfig) updateDefaultBackground( from: newConfig, source: "reloadConfiguration(source=\(source))", scope: .unscoped, forceNotify: renderingModeChanged ) + let effectiveReloadColorScheme = effectiveTerminalColorSchemePreference + synchronizeGhosttyRuntimeColorScheme(effectiveReloadColorScheme, source: "reloadConfiguration:\(source):resolved") + ghostty_app_update_config(app, newConfig) DispatchQueue.main.async { self.applyBackgroundToKeyWindow() } @@ -3215,7 +3229,7 @@ class GhosttyApp { NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload( source: source, - preferredColorScheme: reloadColorScheme + preferredColorScheme: effectiveReloadColorScheme ) logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } @@ -3508,9 +3522,13 @@ class GhosttyApp { let previousCursorTextHex = defaultCursorTextColor.hexString() let previousSelectionBackgroundHex = defaultSelectionBackground.hexString() let previousSelectionForegroundHex = defaultSelectionForeground.hexString() + let previousColorScheme = effectiveTerminalColorSchemePreference defaultBackgroundColor = color defaultBackgroundOpacity = opacity defaultBackgroundBlur = backgroundBlur + effectiveTerminalColorSchemePreference = Self.terminalRuntimeColorSchemePreference( + forBackgroundColor: color + ) if let foregroundColor { defaultForegroundColor = foregroundColor } @@ -3534,13 +3552,14 @@ class GhosttyApp { previousCursorHex != defaultCursorColor.hexString() || previousCursorTextHex != defaultCursorTextColor.hexString() || previousSelectionBackgroundHex != defaultSelectionBackground.hexString() || - previousSelectionForegroundHex != defaultSelectionForeground.hexString() + previousSelectionForegroundHex != defaultSelectionForeground.hexString() || + previousColorScheme != effectiveTerminalColorSchemePreference if hasChanged { notifyDefaultBackgroundDidChange(source: source) } if backgroundLogEnabled { logBackground( - "default appearance updated source=\(source) scope=\(scope.logLabel) previousScope=\(previousScope.logLabel) previousScopeSource=\(previousScopeSource) previousBg=\(previousHex) previousFg=\(previousForegroundHex) previousOpacity=\(String(format: "%.3f", previousOpacity)) previousBlur=\(previousBlur) bg=\(defaultBackgroundColor.hexString()) fg=\(defaultForegroundColor.hexString()) cursor=\(defaultCursorColor.hexString()) cursorText=\(defaultCursorTextColor.hexString()) selectionBg=\(defaultSelectionBackground.hexString()) selectionFg=\(defaultSelectionForeground.hexString()) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) blur=\(defaultBackgroundBlur) changed=\(hasChanged) forced=\(forceNotify)" + "default appearance updated source=\(source) scope=\(scope.logLabel) previousScope=\(previousScope.logLabel) previousScopeSource=\(previousScopeSource) previousBg=\(previousHex) previousFg=\(previousForegroundHex) previousOpacity=\(String(format: "%.3f", previousOpacity)) previousBlur=\(previousBlur) previousScheme=\(previousColorScheme) bg=\(defaultBackgroundColor.hexString()) fg=\(defaultForegroundColor.hexString()) cursor=\(defaultCursorColor.hexString()) cursorText=\(defaultCursorTextColor.hexString()) selectionBg=\(defaultSelectionBackground.hexString()) selectionFg=\(defaultSelectionForeground.hexString()) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) blur=\(defaultBackgroundBlur) scheme=\(effectiveTerminalColorSchemePreference) changed=\(hasChanged) forced=\(forceNotify)" ) } } @@ -3886,6 +3905,10 @@ class GhosttyApp { source: "action.config_change.app", scope: .app ) + synchronizeGhosttyRuntimeColorScheme( + effectiveTerminalColorSchemePreference, + source: "action.config_change.app:resolved" + ) DispatchQueue.main.async { self.applyBackgroundToKeyWindow() } @@ -4159,6 +4182,17 @@ class GhosttyApp { source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", scope: .surface ) + let effectiveConfigChangeColorScheme = effectiveTerminalColorSchemePreference + synchronizeGhosttyRuntimeColorScheme( + effectiveConfigChangeColorScheme, + source: "action.config_change.surface:resolved" + ) + DispatchQueue.main.async { + surfaceView.applySurfaceColorScheme( + force: true, + preferredColorScheme: effectiveConfigChangeColorScheme + ) + } if backgroundLogEnabled { logBackground( "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())" @@ -4171,7 +4205,7 @@ class GhosttyApp { "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { - let preferredColorScheme = GhosttyConfig.currentColorSchemePreference() + let preferredColorScheme = self.effectiveTerminalColorSchemePreference surfaceView.terminalSurface?.hostedView.reapplySurfaceColorSchemeAfterGhosttyConfigReload( preferredColorScheme: preferredColorScheme ) @@ -6760,16 +6794,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return true } - static func surfaceColorSchemePreference( - appPreferredColorScheme: GhosttyConfig.ColorSchemePreference, - surfaceAppearanceBestMatch _: NSAppearance.Name? - ) -> GhosttyConfig.ColorSchemePreference { - // The terminal config loader already resolves light/dark theme pairs from cmux's - // appearance setting. AppKit effectiveAppearance can lag behind that after live - // theme changes, so using it here can put Ghostty surfaces on the opposite theme. - appPreferredColorScheme - } - // Visibility is used for focus gating. Explicit portal visibility transitions // also drive Ghostty occlusion so hidden workspace/split surfaces pause and // queue a redraw when they become visible again. @@ -7297,10 +7321,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) - let preferredColorScheme = Self.surfaceColorSchemePreference( - appPreferredColorScheme: preferredColorScheme ?? GhosttyConfig.currentColorSchemePreference(), - surfaceAppearanceBestMatch: bestMatch - ) + let preferredColorScheme = preferredColorScheme + ?? GhosttyApp.shared.effectiveTerminalColorSchemePreference let scheme = GhosttyApp.ghosttyRuntimeColorScheme(for: preferredColorScheme) if !force, appliedColorScheme == scheme { if GhosttyApp.shared.backgroundLogEnabled { diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 230344f05a11..46bc7541f844 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -238,14 +238,11 @@ func sidebarSelectedWorkspaceForegroundNSColor( usesDefaultSelectionColor: Bool = false ) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) - if usesDefaultSelectionColor { - return cmuxReadableForegroundNSColor( - preferred: NSColor.white.withAlphaComponent(clampedOpacity), - on: backgroundColor, - minimumContrast: 3.0 - ) - } - return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: clampedOpacity) + return cmuxReadableForegroundNSColor( + preferred: NSColor.white.withAlphaComponent(clampedOpacity), + on: backgroundColor, + minimumContrast: 3.0 + ) } struct SidebarWorkspaceRowBackgroundStyle { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index bfac525e8a2e..66febfd60797 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -589,6 +589,21 @@ final class GhosttyConfigTests: XCTestCase { } } + func testTerminalRuntimeColorSchemeFollowsResolvedThemeBackground() { + XCTAssertEqual( + GhosttyApp.terminalRuntimeColorSchemePreference( + forBackgroundColor: NSColor(hex: "#F7F7F7")! + ), + .light + ) + XCTAssertEqual( + GhosttyApp.terminalRuntimeColorSchemePreference( + forBackgroundColor: NSColor(hex: "#090300")! + ), + .dark + ) + } + func testScrollLagCaptureRequiresSustainedLag() { let cases: [(samples: Int, averageMs: Double, maxMs: Double, expected: Bool)] = [ (4, 18, 85, false), diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index bc1fee99f0f2..39afe49a7ffe 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -143,6 +143,21 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { ) } + func testSelectedForegroundPrefersWhiteForSaturatedSelectionBackground() throws { + let background = try XCTUnwrap(NSColor(hex: "#0088FF")) + let foreground = sidebarSelectedWorkspaceForegroundNSColor( + on: background, + opacity: 1.0, + usesDefaultSelectionColor: false + ) + + assertColor(foreground, equals: .white) + XCTAssertGreaterThanOrEqual( + cmuxContrastRatio(foreground: foreground, background: background), + 3.0 + ) + } + func testTitlebarControlForegroundContrastsWithLightTerminalBackground() throws { let background = try XCTUnwrap(NSColor(hex: "#F7F7F7")) let snapshot = makeWindowAppearanceSnapshot(background: background) From 4ac5023e179f092eb789a7d3f642dd030a88d3c5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 05:15:35 -0700 Subject: [PATCH 28/39] fix: clear theme contrast ci feedback --- Sources/ContentView.swift | 3 +-- .../Sidebar/SidebarAppearanceSupport.swift | 6 ++---- cmuxTests/SidebarWidthPolicyTests.swift | 6 ++---- cmuxTests/TerminalAndGhosttyTests.swift | 19 ------------------- cmuxTests/WorkspaceUnitTests.swift | 3 +-- 5 files changed, 6 insertions(+), 31 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 47d68171bb46..0df00b2a098a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12434,8 +12434,7 @@ private struct TabItemView: View, Equatable { private func selectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { sidebarSelectedWorkspaceForegroundNSColor( on: selectedWorkspaceBackgroundNSColor, - opacity: opacity, - usesDefaultSelectionColor: sidebarSelectionColorHex == nil + opacity: opacity ) } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 46bc7541f844..64a744e3c533 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -227,15 +227,13 @@ func sidebarSelectedWorkspaceBackgroundNSColor( func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { sidebarSelectedWorkspaceForegroundNSColor( on: sidebarSelectedWorkspaceBackgroundNSColor(for: .dark), - opacity: opacity, - usesDefaultSelectionColor: true + opacity: opacity ) } func sidebarSelectedWorkspaceForegroundNSColor( on backgroundColor: NSColor, - opacity: CGFloat, - usesDefaultSelectionColor: Bool = false + opacity: CGFloat ) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) return cmuxReadableForegroundNSColor( diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index 39afe49a7ffe..e5d8ebeae6ba 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -132,8 +132,7 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { let background = try XCTUnwrap(NSColor(hex: "#F7F7F7")) let foreground = sidebarSelectedWorkspaceForegroundNSColor( on: background, - opacity: 1.0, - usesDefaultSelectionColor: true + opacity: 1.0 ) assertColor(foreground, equals: .black) @@ -147,8 +146,7 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { let background = try XCTUnwrap(NSColor(hex: "#0088FF")) let foreground = sidebarSelectedWorkspaceForegroundNSColor( on: background, - opacity: 1.0, - usesDefaultSelectionColor: false + opacity: 1.0 ) assertColor(foreground, equals: .white) diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index c27e80bb386b..6aeb9f00e756 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -1814,25 +1814,6 @@ final class GhosttyBackgroundThemeTests: XCTestCase { } } -final class GhosttySurfaceColorSchemeTests: XCTestCase { - func testSurfaceColorSchemeFollowsAppPreferenceNotEffectiveAppearance() { - XCTAssertEqual( - GhosttyNSView.surfaceColorSchemePreference( - appPreferredColorScheme: .light, - surfaceAppearanceBestMatch: .darkAqua - ), - .light - ) - XCTAssertEqual( - GhosttyNSView.surfaceColorSchemePreference( - appPreferredColorScheme: .dark, - surfaceAppearanceBestMatch: .aqua - ), - .dark - ) - } -} - final class PanelAppearanceBackgroundTests: XCTestCase { func testTransparentGhosttyOpacityUsesClearContentBackground() { var config = GhosttyConfig() diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 46f31591edce..6ccd3589b258 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -81,8 +81,7 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { func testDefaultSelectedWorkspaceForegroundUsesNativeSelectionTextOnAccentBackground() { guard let color = sidebarSelectedWorkspaceForegroundNSColor( on: sidebarSelectedWorkspaceBackgroundNSColor(for: .light), - opacity: 0.65, - usesDefaultSelectionColor: true + opacity: 0.65 ).usingColorSpace(.sRGB) else { XCTFail("Expected sRGB-convertible color") return From 5aa5d0a50fd88775155061e93f9109144b9a4f1b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 05:27:33 -0700 Subject: [PATCH 29/39] fix: guard ghostty theme reload recursion --- Sources/ContentView.swift | 1 + Sources/GhosttyTerminalView.swift | 76 ++++++++++++++++++- .../Sidebar/SidebarAppearanceSupport.swift | 10 +-- cmuxTests/GhosttyConfigTests.swift | 27 +++++++ cmuxTests/SidebarWidthPolicyTests.swift | 10 +++ 5 files changed, 118 insertions(+), 6 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0df00b2a098a..27c2898a80fe 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12828,6 +12828,7 @@ private struct TabItemView: View, Equatable { if settings.makesPullRequestsClickable { Button(action: { openPullRequestLink(pullRequest.url) }) { rowContent } .buttonStyle(.plain) + .tint(pullRequestForegroundColor) .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequestTitle)")) .accessibilityIdentifier("SidebarPullRequestRow") } else { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fec16c5e13d5..3dec715bb176 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1697,6 +1697,9 @@ class GhosttyApp { private(set) var defaultSelectionBackground: NSColor = GhosttyApp.fallbackAppearanceConfig.selectionBackground private(set) var defaultSelectionForeground: NSColor = GhosttyApp.fallbackAppearanceConfig.selectionForeground private(set) var effectiveTerminalColorSchemePreference: GhosttyConfig.ColorSchemePreference = .dark + private var appliedGhosttyRuntimeColorScheme: ghostty_color_scheme_e? + private var runtimeColorSchemeSynchronizationDepth = 0 + private var reloadConfigurationDepth = 0 private(set) var usesHostLayerBackground = false private(set) var userGhosttyShellIntegrationMode: String = "detect" private static func resolveBackgroundLogURL( @@ -3010,6 +3013,26 @@ class GhosttyApp { } } + enum RuntimeColorSchemeSynchronizationDecision: Equatable { + case apply + case skipAlreadyApplied + case skipReentrant + } + + static func runtimeColorSchemeSynchronizationDecision( + applied: ghostty_color_scheme_e?, + requested: ghostty_color_scheme_e, + isSynchronizing: Bool + ) -> RuntimeColorSchemeSynchronizationDecision { + if isSynchronizing { + return .skipReentrant + } + if applied == requested { + return .skipAlreadyApplied + } + return .apply + } + static func appearanceSynchronizationPlan( previousColorScheme: GhosttyConfig.ColorSchemePreference?, currentColorScheme: GhosttyConfig.ColorSchemePreference @@ -3162,6 +3185,13 @@ class GhosttyApp { reloadSettingsFromFile: Bool = true, preferredColorScheme: GhosttyConfig.ColorSchemePreference? = nil ) { + guard reloadConfigurationDepth == 0 else { + logThemeAction("reload skipped source=\(source) soft=\(soft) reason=reentrant") + return + } + reloadConfigurationDepth += 1 + defer { reloadConfigurationDepth -= 1 } + if reloadSettingsFromFile { KeyboardShortcutSettings.settingsFileStore.reload() } @@ -3300,6 +3330,31 @@ class GhosttyApp { source: String ) { guard let app else { return } + let decision = Self.runtimeColorSchemeSynchronizationDecision( + applied: appliedGhosttyRuntimeColorScheme, + requested: runtimeColorScheme, + isSynchronizing: runtimeColorSchemeSynchronizationDepth > 0 + ) + guard decision == .apply else { + if backgroundLogEnabled { + let schemeLabel = colorScheme == .dark ? "dark" : "light" + let reason: String + switch decision { + case .apply: + reason = "apply" + case .skipAlreadyApplied: + reason = "already_applied" + case .skipReentrant: + reason = "reentrant" + } + logBackground("app color scheme skipped source=\(source) scheme=\(schemeLabel) reason=\(reason)") + } + return + } + + appliedGhosttyRuntimeColorScheme = runtimeColorScheme + runtimeColorSchemeSynchronizationDepth += 1 + defer { runtimeColorSchemeSynchronizationDepth -= 1 } ghostty_app_set_color_scheme(app, runtimeColorScheme) if backgroundLogEnabled { let schemeLabel = colorScheme == .dark ? "dark" : "light" @@ -3307,6 +3362,15 @@ class GhosttyApp { } } + private func shouldProcessGhosttyReloadAction(source: String, soft: Bool) -> Bool { + guard reloadConfigurationDepth == 0, + runtimeColorSchemeSynchronizationDepth == 0 else { + logThemeAction("reload request skipped source=\(source) soft=\(soft) reason=reentrant") + return false + } + return true + } + func openConfigurationInTextEdit() { #if os(macOS) let environment = ConfigSourceEnvironment.live() @@ -3887,6 +3951,12 @@ class GhosttyApp { let soft = action.action.reload_config.soft logThemeAction("reload request target=app soft=\(soft)") performOnMain { + guard self.shouldProcessGhosttyReloadAction( + source: "action.reload_config.app", + soft: soft + ) else { + return + } self.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true @@ -4201,10 +4271,14 @@ class GhosttyApp { return true case GHOSTTY_ACTION_RELOAD_CONFIG: let soft = action.action.reload_config.soft + let source = "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" logThemeAction( "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { + guard self.shouldProcessGhosttyReloadAction(source: source, soft: soft) else { + return true + } let preferredColorScheme = self.effectiveTerminalColorSchemePreference surfaceView.terminalSurface?.hostedView.reapplySurfaceColorSchemeAfterGhosttyConfigReload( preferredColorScheme: preferredColorScheme @@ -4212,7 +4286,7 @@ class GhosttyApp { self.reloadSurfaceConfiguration( target.target.surface, soft: soft, - source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", + source: source, preferredColorScheme: preferredColorScheme ) surfaceView.terminalSurface?.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 64a744e3c533..a13a71f14c3c 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -236,11 +236,11 @@ func sidebarSelectedWorkspaceForegroundNSColor( opacity: CGFloat ) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) - return cmuxReadableForegroundNSColor( - preferred: NSColor.white.withAlphaComponent(clampedOpacity), - on: backgroundColor, - minimumContrast: 3.0 - ) + let whiteContrast = cmuxContrastRatio(foreground: .white, background: backgroundColor) + guard whiteContrast < 2.75 else { + return NSColor.white.withAlphaComponent(clampedOpacity) + } + return cmuxReadableForegroundNSColor(on: backgroundColor, opacity: clampedOpacity) } struct SidebarWorkspaceRowBackgroundStyle { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 66febfd60797..84ddb7738001 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -604,6 +604,33 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testRuntimeColorSchemeSynchronizationDecisionSkipsReentrantAndUnchangedSchemes() { + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeSynchronizationDecision( + applied: nil, + requested: GHOSTTY_COLOR_SCHEME_DARK, + isSynchronizing: false + ), + .apply + ) + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeSynchronizationDecision( + applied: GHOSTTY_COLOR_SCHEME_DARK, + requested: GHOSTTY_COLOR_SCHEME_DARK, + isSynchronizing: false + ), + .skipAlreadyApplied + ) + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeSynchronizationDecision( + applied: GHOSTTY_COLOR_SCHEME_LIGHT, + requested: GHOSTTY_COLOR_SCHEME_DARK, + isSynchronizing: true + ), + .skipReentrant + ) + } + func testScrollLagCaptureRequiresSustainedLag() { let cases: [(samples: Int, averageMs: Double, maxMs: Double, expected: Bool)] = [ (4, 18, 85, false), diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index e5d8ebeae6ba..7cfd2c96f619 100644 --- a/cmuxTests/SidebarWidthPolicyTests.swift +++ b/cmuxTests/SidebarWidthPolicyTests.swift @@ -156,6 +156,16 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase { ) } + func testSelectedForegroundKeepsWhiteForStandardInactiveSelectionBlue() throws { + let background = try XCTUnwrap(NSColor(hex: "#6795F5")) + let foreground = sidebarSelectedWorkspaceForegroundNSColor( + on: background, + opacity: 0.75 + ) + + assertColor(foreground, equals: NSColor.white.withAlphaComponent(0.75)) + } + func testTitlebarControlForegroundContrastsWithLightTerminalBackground() throws { let background = try XCTUnwrap(NSColor(hex: "#F7F7F7")) let snapshot = makeWindowAppearanceSnapshot(background: background) From 1ca833f74563f7ff7decd7e2c82d683223594ff7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 05:35:54 -0700 Subject: [PATCH 30/39] fix: reapply ghostty runtime scheme on theme reload --- Sources/GhosttyTerminalView.swift | 10 ++-------- cmuxTests/GhosttyConfigTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9cd93d2b94da..2a72f3ef3db9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3015,21 +3015,17 @@ class GhosttyApp { enum RuntimeColorSchemeSynchronizationDecision: Equatable { case apply - case skipAlreadyApplied case skipReentrant } static func runtimeColorSchemeSynchronizationDecision( - applied: ghostty_color_scheme_e?, - requested: ghostty_color_scheme_e, + applied _: ghostty_color_scheme_e?, + requested _: ghostty_color_scheme_e, isSynchronizing: Bool ) -> RuntimeColorSchemeSynchronizationDecision { if isSynchronizing { return .skipReentrant } - if applied == requested { - return .skipAlreadyApplied - } return .apply } @@ -3342,8 +3338,6 @@ class GhosttyApp { switch decision { case .apply: reason = "apply" - case .skipAlreadyApplied: - reason = "already_applied" case .skipReentrant: reason = "reentrant" } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9867688a5c80..0fad9cf726d2 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -604,7 +604,7 @@ final class GhosttyConfigTests: XCTestCase { ) } - func testRuntimeColorSchemeSynchronizationDecisionSkipsReentrantAndUnchangedSchemes() { + func testRuntimeColorSchemeSynchronizationDecisionOnlySkipsReentrantCalls() { XCTAssertEqual( GhosttyApp.runtimeColorSchemeSynchronizationDecision( applied: nil, @@ -619,7 +619,7 @@ final class GhosttyConfigTests: XCTestCase { requested: GHOSTTY_COLOR_SCHEME_DARK, isSynchronizing: false ), - .skipAlreadyApplied + .apply ) XCTAssertEqual( GhosttyApp.runtimeColorSchemeSynchronizationDecision( From ebe8a89271a8f52ee3e1ca64fca98872c88ebf7d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:26:23 -0700 Subject: [PATCH 31/39] fix: simplify ghostty reentrancy skip logging --- Sources/GhosttyTerminalView.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2a72f3ef3db9..2c0a6d2da170 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3334,14 +3334,7 @@ class GhosttyApp { guard decision == .apply else { if backgroundLogEnabled { let schemeLabel = colorScheme == .dark ? "dark" : "light" - let reason: String - switch decision { - case .apply: - reason = "apply" - case .skipReentrant: - reason = "reentrant" - } - logBackground("app color scheme skipped source=\(source) scheme=\(schemeLabel) reason=\(reason)") + logBackground("app color scheme skipped source=\(source) scheme=\(schemeLabel) reason=reentrant") } return } From f594d5a808dcf84a9c7492a03bf074071df85877 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:36:34 -0700 Subject: [PATCH 32/39] fix: reuse readable color scheme for foreground --- Sources/Sidebar/SidebarAppearanceSupport.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index a13a71f14c3c..8503dcc62864 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -144,10 +144,7 @@ func cmuxContrastRatio(foreground: NSColor, background: NSColor) -> CGFloat { } private func cmuxReadableForegroundBaseColor(on backgroundColor: NSColor) -> NSColor { - let backgroundLuminance = cmuxRelativeLuminance(backgroundColor) - let whiteContrast = cmuxContrastRatio(backgroundLuminance, 1.0) - let blackContrast = cmuxContrastRatio(backgroundLuminance, 0.0) - return whiteContrast >= blackContrast ? .white : .black + cmuxReadableColorScheme(for: backgroundColor) == .dark ? .white : .black } private func cmuxRelativeLuminance(_ color: NSColor) -> CGFloat { From cbdbe76f5825db376a2648a42e8e5c4b3ca29931 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:45:56 -0700 Subject: [PATCH 33/39] fix: simplify runtime scheme sync decision API --- Sources/GhosttyTerminalView.swift | 4 ---- cmuxTests/GhosttyConfigTests.swift | 12 ------------ 2 files changed, 16 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2c0a6d2da170..5a5ae6d6b66a 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3019,8 +3019,6 @@ class GhosttyApp { } static func runtimeColorSchemeSynchronizationDecision( - applied _: ghostty_color_scheme_e?, - requested _: ghostty_color_scheme_e, isSynchronizing: Bool ) -> RuntimeColorSchemeSynchronizationDecision { if isSynchronizing { @@ -3327,8 +3325,6 @@ class GhosttyApp { ) { guard let app else { return } let decision = Self.runtimeColorSchemeSynchronizationDecision( - applied: appliedGhosttyRuntimeColorScheme, - requested: runtimeColorScheme, isSynchronizing: runtimeColorSchemeSynchronizationDepth > 0 ) guard decision == .apply else { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 0fad9cf726d2..9960a6d41b38 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -607,24 +607,12 @@ final class GhosttyConfigTests: XCTestCase { func testRuntimeColorSchemeSynchronizationDecisionOnlySkipsReentrantCalls() { XCTAssertEqual( GhosttyApp.runtimeColorSchemeSynchronizationDecision( - applied: nil, - requested: GHOSTTY_COLOR_SCHEME_DARK, isSynchronizing: false ), .apply ) XCTAssertEqual( GhosttyApp.runtimeColorSchemeSynchronizationDecision( - applied: GHOSTTY_COLOR_SCHEME_DARK, - requested: GHOSTTY_COLOR_SCHEME_DARK, - isSynchronizing: false - ), - .apply - ) - XCTAssertEqual( - GhosttyApp.runtimeColorSchemeSynchronizationDecision( - applied: GHOSTTY_COLOR_SCHEME_LIGHT, - requested: GHOSTTY_COLOR_SCHEME_DARK, isSynchronizing: true ), .skipReentrant From 15709e4396ba4d88e3d325e9ad8815dc5914d9ad Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 16:54:26 -0700 Subject: [PATCH 34/39] test: cover cmux theme reload notifications --- .../CMUXCLIErrorOutputRegressionTests.swift | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index f6186c27a486..b53c5e15b4a5 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -1,4 +1,5 @@ import Darwin +import Foundation import XCTest final class CMUXCLIErrorOutputRegressionTests: XCTestCase { @@ -145,6 +146,29 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { let socketPath = "/tmp/cmux-theme-\(UUID().uuidString.prefix(8)).sock" let responder = try UnixSocketResponder(path: socketPath, response: "OK") defer { responder.stop() } + let bundleIdentifier = "com.cmuxterm.app.debug.issue-4355-test" + let reloadExpectation = expectation(description: "cmux themes set posts final reload notifications") + reloadExpectation.expectedFulfillmentCount = 3 + let notificationQueue = OperationQueue() + notificationQueue.maxConcurrentOperationCount = 1 + let notificationLock = NSLock() + var observedReloads: [(bundleIdentifier: String?, phase: String?)] = [] + let observer = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.cmuxterm.themes.reload-config"), + object: nil, + queue: notificationQueue + ) { notification in + let observedBundleIdentifier = notification.userInfo?["bundleIdentifier"] as? String + guard observedBundleIdentifier == bundleIdentifier else { return } + let observedPhase = notification.userInfo?["phase"] as? String + notificationLock.lock() + observedReloads.append((bundleIdentifier: observedBundleIdentifier, phase: observedPhase)) + notificationLock.unlock() + reloadExpectation.fulfill() + } + defer { + DistributedNotificationCenter.default().removeObserver(observer) + } var environment = ProcessInfo.processInfo.environment for key in Array(environment.keys) where key.hasPrefix("CMUX_") { @@ -154,7 +178,7 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { environment["HOME"] = root.path environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path environment["CMUX_SOCKET_PATH"] = socketPath - environment["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.issue-4355-test" + environment["CMUX_BUNDLE_ID"] = bundleIdentifier environment["CMUX_CLI_SENTRY_DISABLED"] = "1" let configURL = root @@ -176,17 +200,19 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { XCTAssertEqual(result.status, 0, result.stdout) observedThemeValues.append(try managedThemeValue(in: configURL)) } + wait(for: [reloadExpectation], timeout: 5) XCTAssertEqual(observedThemeValues, [ "light:Theme A,dark:Theme A", "light:Theme B,dark:Theme B", "light:Theme C,dark:Theme C", ]) - XCTAssertEqual(responder.receivedRequests, [ - "reload_config", - "reload_config", - "reload_config", - ]) + notificationLock.lock() + let reloads = observedReloads + notificationLock.unlock() + XCTAssertEqual(reloads.map { $0.bundleIdentifier }, Array(repeating: bundleIdentifier, count: 3)) + XCTAssertEqual(reloads.map { $0.phase }, Array(repeating: "final", count: 3)) + XCTAssertEqual(responder.receivedRequests, []) } func testBareInteractiveThemesReloadsRunningAppAfterPickerExits() throws { @@ -232,6 +258,28 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { let socketPath = "/tmp/cmux-theme-picker-\(UUID().uuidString.prefix(8)).sock" let responder = try UnixSocketResponder(path: socketPath, response: "OK") defer { responder.stop() } + let bundleIdentifier = "com.cmuxterm.app.debug.theme-picker.\(UUID().uuidString.lowercased())" + let reloadExpectation = expectation(description: "bare cmux themes posts final reload notification") + let notificationQueue = OperationQueue() + notificationQueue.maxConcurrentOperationCount = 1 + let notificationLock = NSLock() + var observedReloads: [(bundleIdentifier: String?, phase: String?)] = [] + let observer = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.cmuxterm.themes.reload-config"), + object: nil, + queue: notificationQueue + ) { notification in + let observedBundleIdentifier = notification.userInfo?["bundleIdentifier"] as? String + guard observedBundleIdentifier == bundleIdentifier else { return } + let observedPhase = notification.userInfo?["phase"] as? String + notificationLock.lock() + observedReloads.append((bundleIdentifier: observedBundleIdentifier, phase: observedPhase)) + notificationLock.unlock() + reloadExpectation.fulfill() + } + defer { + DistributedNotificationCenter.default().removeObserver(observer) + } let command = [ "env", @@ -239,6 +287,7 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { "HOME=\(shellSingleQuote(root.path))", "CFFIXED_USER_HOME=\(shellSingleQuote(root.path))", "CMUX_SOCKET_PATH=\(shellSingleQuote(socketPath))", + "CMUX_BUNDLE_ID=\(shellSingleQuote(bundleIdentifier))", "CMUX_CLI_SENTRY_DISABLED=1", "PATH=/usr/bin:/bin", "/usr/bin/script", @@ -251,7 +300,13 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { XCTAssertFalse(result.timedOut, result.stdout) XCTAssertEqual(result.status, 0, result.stdout) - XCTAssertEqual(responder.receivedRequests, ["reload_config"]) + wait(for: [reloadExpectation], timeout: 5) + notificationLock.lock() + let reloads = observedReloads + notificationLock.unlock() + XCTAssertEqual(reloads.map { $0.bundleIdentifier }, [bundleIdentifier]) + XCTAssertEqual(reloads.map { $0.phase }, ["final"]) + XCTAssertEqual(responder.receivedRequests, []) } func testBareInteractiveThemesTreatsSigintAsSilentCancel() throws { From 59fc0abd4ba9a79f0f852876c3f13e04c1455e77 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 16:54:41 -0700 Subject: [PATCH 35/39] fix: keep cmux theme reloads stateful --- CLI/CMUXCLI+ThemeSupport.swift | 31 ++------ .../GhosttySurfaceConfigurationRefresh.swift | 33 +++++++++ Sources/AppDelegate.swift | 35 +++++++++- Sources/GhosttyConfig.swift | 33 +++++++++ Sources/GhosttyTerminalView.swift | 70 +++++++++++++++++-- cmuxTests/AppearanceSettingsTests.swift | 59 ++++++++++++++++ cmuxTests/GhosttyConfigTests.swift | 54 ++++++++++++++ 7 files changed, 283 insertions(+), 32 deletions(-) diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 2269abefe58b..74181c119090 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -310,40 +310,21 @@ extension CMUXCLI { } func reloadThemesIfPossible( - socketPath: String, - explicitPassword: String? + socketPath _: String, + explicitPassword _: String? ) -> ThemeReloadStatus { let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier - if requestThemeReloadOverSocket(socketPath: socketPath, explicitPassword: explicitPassword) { - return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) - } - DistributedNotificationCenter.default().post( name: Notification.Name(Self.cmuxThemesReloadNotificationName), object: nil, - userInfo: ["bundleIdentifier": bundleIdentifier] + userInfo: [ + "bundleIdentifier": bundleIdentifier, + "phase": "final", + ] ) return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) } - private func requestThemeReloadOverSocket(socketPath: String, explicitPassword: String?) -> Bool { - let client = SocketClient(path: socketPath) - defer { client.close() } - - do { - try client.connect() - try authenticateClientIfNeeded( - client, - explicitPassword: explicitPassword, - socketPath: socketPath - ) - let response = try client.send(command: "reload_config") - return !response.hasPrefix("ERROR:") - } catch { - return false - } - } - func currentCmuxAppBundleIdentifier() -> String? { if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { diff --git a/Sources/App/GhosttySurfaceConfigurationRefresh.swift b/Sources/App/GhosttySurfaceConfigurationRefresh.swift index 9424b2b129bb..bbe57c4e741b 100644 --- a/Sources/App/GhosttySurfaceConfigurationRefresh.swift +++ b/Sources/App/GhosttySurfaceConfigurationRefresh.swift @@ -1,6 +1,39 @@ @MainActor enum GhosttySurfaceConfigurationRefresh { nonisolated static let forceRefreshReason = "appDelegate.refreshAfterGhosttyConfigReload" + nonisolated static let cmuxThemeReloadLegacySource = "distributed.cmux.themes" + nonisolated static let cmuxThemeReloadPreviewSource = "distributed.cmux.themes.preview" + nonisolated static let cmuxThemeReloadFinalSource = "distributed.cmux.themes.final" + nonisolated static let cmuxThemePreviewReloadDebounceMilliseconds = 180 + + nonisolated static func cmuxThemeReloadSource(phase: String?) -> String { + switch phase { + case "final", "apply": + return cmuxThemeReloadFinalSource + case "preview": + return cmuxThemeReloadPreviewSource + default: + return cmuxThemeReloadLegacySource + } + } + + nonisolated static func shouldDebounceCmuxThemeReload(source: String) -> Bool { + switch source { + case cmuxThemeReloadLegacySource, cmuxThemeReloadPreviewSource: + return true + default: + return false + } + } + + nonisolated static func isCmuxThemeReloadSource(_ source: String) -> Bool { + switch source { + case cmuxThemeReloadLegacySource, cmuxThemeReloadPreviewSource, cmuxThemeReloadFinalSource: + return true + default: + return false + } + } static func applyAfterAppConfigReload( to surface: ghostty_surface_t?, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f18a4f5fec64..759587e36680 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -517,6 +517,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var isRunningUnderXCTestCached: Bool { Self.cachedIsRunningUnderXCTest } + private var cmuxThemePreviewReloadGeneration = 0 + private var cmuxThemePreviewReloadWorkItem: DispatchWorkItem? private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { if env["XCTestConfigurationFilePath"] != nil { return true } @@ -14922,9 +14924,40 @@ private extension AppDelegate { return } + let source = GhosttySurfaceConfigurationRefresh.cmuxThemeReloadSource( + phase: notification.userInfo?["phase"] as? String + ) DispatchQueue.main.async { - GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") + self.reloadGhosttyConfigurationForCmuxThemeSource(source) + } + } + + func reloadGhosttyConfigurationForCmuxThemeSource(_ source: String) { + if GhosttySurfaceConfigurationRefresh.shouldDebounceCmuxThemeReload(source: source) { + cmuxThemePreviewReloadGeneration += 1 + let generation = cmuxThemePreviewReloadGeneration + cmuxThemePreviewReloadWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self, + self.cmuxThemePreviewReloadGeneration == generation else { return } + self.cmuxThemePreviewReloadWorkItem = nil + GhosttyApp.shared.reloadConfiguration(source: source) + } + cmuxThemePreviewReloadWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + .milliseconds( + GhosttySurfaceConfigurationRefresh.cmuxThemePreviewReloadDebounceMilliseconds + ), + execute: workItem + ) + return } + + cmuxThemePreviewReloadGeneration += 1 + cmuxThemePreviewReloadWorkItem?.cancel() + cmuxThemePreviewReloadWorkItem = nil + GhosttyApp.shared.reloadConfiguration(source: source) } } diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 93380595922b..b6215dbdad6c 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -579,6 +579,39 @@ struct GhosttyConfig { return rawThemeValue.trimmingCharacters(in: .whitespacesAndNewlines) } + static func themeValueUsesSameResolvedThemeInBothColorSchemes(_ rawThemeValue: String) -> Bool { + let lightTheme = resolveThemeName(from: rawThemeValue, preferredColorScheme: .light) + .trimmingCharacters(in: .whitespacesAndNewlines) + let darkTheme = resolveThemeName(from: rawThemeValue, preferredColorScheme: .dark) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !lightTheme.isEmpty, !darkTheme.isEmpty else { return false } + return lightTheme.caseInsensitiveCompare(darkTheme) == .orderedSame + } + + static func lastThemeDirective(in contents: String) -> String? { + var lastValue: String? + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } + + let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + guard parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { continue } + + let value = parts[1] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + if !value.isEmpty { + lastValue = value + } + } + + return lastValue + } + static func themeNameCandidates(from rawName: String) -> [String] { var candidates: [String] = [] let compatibilityAliasGroups: [[String]] = [ diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5a5ae6d6b66a..0d008e85afdb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3019,6 +3019,8 @@ class GhosttyApp { } static func runtimeColorSchemeSynchronizationDecision( + applied _: ghostty_color_scheme_e?, + requested _: ghostty_color_scheme_e, isSynchronizing: Bool ) -> RuntimeColorSchemeSynchronizationDecision { if isSynchronizing { @@ -3061,6 +3063,21 @@ class GhosttyApp { cmuxReadableColorScheme(for: backgroundColor) == .light ? .light : .dark } + static func runtimeColorSchemeForConfigLoad( + source: String, + requestedColorScheme: GhosttyConfig.ColorSchemePreference, + effectiveTerminalColorScheme: GhosttyConfig.ColorSchemePreference, + cmuxThemeValue: String? + ) -> GhosttyConfig.ColorSchemePreference { + guard GhosttySurfaceConfigurationRefresh.isCmuxThemeReloadSource(source), + let cmuxThemeValue, + GhosttyConfig.themeValueUsesSameResolvedThemeInBothColorSchemes(cmuxThemeValue) else { + return requestedColorScheme + } + + return effectiveTerminalColorScheme + } + static func shouldCaptureScrollLagEvent( samples: Int, averageMs: Double, @@ -3107,7 +3124,33 @@ class GhosttyApp { cmuxDebugLog( "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" ) -#endif + #endif + #endif + } + + private func currentCmuxAppSupportThemeValue() -> String? { + #if os(macOS) + let fm = FileManager.default + guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let urls = Self.cmuxAppSupportConfigURLs( + currentBundleIdentifier: Bundle.main.bundleIdentifier, + appSupportDirectory: appSupport, + fileManager: fm + ) + + var lastValue: String? + for url in urls { + guard let contents = try? String(contentsOf: url, encoding: .utf8), + let value = GhosttyConfig.lastThemeDirective(in: contents) else { + continue + } + lastValue = value + } + return lastValue + #else + return nil #endif } @@ -3203,10 +3246,16 @@ class GhosttyApp { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return } - // Use the appearance preference only while loading config so Ghostty can resolve - // conditional theme pairs. After the config resolves, rendering follows the - // terminal background itself; single explicit light themes can be used in dark app mode. - synchronizeGhosttyRuntimeColorScheme(reloadColorScheme, source: "reloadConfiguration:\(source):load") + // Use the appearance preference while loading conditional theme pairs. For cmux + // single-theme reloads, keep the resolved terminal scheme stable until the new + // background is known so same-scheme theme changes do not flash through app mode. + let loadColorScheme = Self.runtimeColorSchemeForConfigLoad( + source: source, + requestedColorScheme: reloadColorScheme, + effectiveTerminalColorScheme: effectiveTerminalColorSchemePreference, + cmuxThemeValue: currentCmuxAppSupportThemeValue() + ) + synchronizeGhosttyRuntimeColorScheme(loadColorScheme, source: "reloadConfiguration:\(source):load") logThemeAction("reload begin source=\(source) soft=\(soft)") resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { @@ -3325,12 +3374,21 @@ class GhosttyApp { ) { guard let app else { return } let decision = Self.runtimeColorSchemeSynchronizationDecision( + applied: appliedGhosttyRuntimeColorScheme, + requested: runtimeColorScheme, isSynchronizing: runtimeColorSchemeSynchronizationDepth > 0 ) guard decision == .apply else { if backgroundLogEnabled { let schemeLabel = colorScheme == .dark ? "dark" : "light" - logBackground("app color scheme skipped source=\(source) scheme=\(schemeLabel) reason=reentrant") + let reason: String + switch decision { + case .apply: + reason = "apply" + case .skipReentrant: + reason = "reentrant" + } + logBackground("app color scheme skipped source=\(source) scheme=\(schemeLabel) reason=\(reason)") } return } diff --git a/cmuxTests/AppearanceSettingsTests.swift b/cmuxTests/AppearanceSettingsTests.swift index d9f22279d6b3..ccae2218141c 100644 --- a/cmuxTests/AppearanceSettingsTests.swift +++ b/cmuxTests/AppearanceSettingsTests.swift @@ -158,6 +158,65 @@ final class AppearanceSettingsTests: XCTestCase { ]) } + func testAppConfigReloadRefreshAppliesSurfaceColorSchemeForPreviewReload() throws { + let fakeSurface = try XCTUnwrap(UnsafeMutableRawPointer(bitPattern: 0x3852)) + var events: [String] = [] + + GhosttySurfaceConfigurationRefresh.applyAfterAppConfigReload( + to: fakeSurface, + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadPreviewSource, + reloadSurfaceConfiguration: { _, soft, source in + XCTAssertTrue(soft) + events.append("reload:\(source)") + }, + applySurfaceColorScheme: { + events.append("color-scheme") + }, + refreshHostBackground: { + events.append("host-background") + }, + forceRefresh: { reason in + events.append("force-refresh:\(reason)") + } + ) + + XCTAssertEqual(events, [ + "color-scheme", + "reload:\(GhosttySurfaceConfigurationRefresh.cmuxThemeReloadPreviewSource)", + "host-background", + "force-refresh:\(GhosttySurfaceConfigurationRefresh.forceRefreshReason)" + ]) + } + + func testCmuxThemeFinalReloadUsesFinalSource() { + XCTAssertEqual( + GhosttySurfaceConfigurationRefresh.cmuxThemeReloadSource(phase: "final"), + GhosttySurfaceConfigurationRefresh.cmuxThemeReloadFinalSource + ) + } + + func testCmuxThemePreviewReloadIsDebounced() { + XCTAssertEqual( + GhosttySurfaceConfigurationRefresh.cmuxThemeReloadSource(phase: "preview"), + GhosttySurfaceConfigurationRefresh.cmuxThemeReloadPreviewSource + ) + XCTAssertTrue( + GhosttySurfaceConfigurationRefresh.shouldDebounceCmuxThemeReload( + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadPreviewSource + ) + ) + XCTAssertTrue( + GhosttySurfaceConfigurationRefresh.shouldDebounceCmuxThemeReload( + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadLegacySource + ) + ) + XCTAssertFalse( + GhosttySurfaceConfigurationRefresh.shouldDebounceCmuxThemeReload( + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadFinalSource + ) + ) + } + func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 9960a6d41b38..7baedd526706 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -607,18 +607,72 @@ final class GhosttyConfigTests: XCTestCase { func testRuntimeColorSchemeSynchronizationDecisionOnlySkipsReentrantCalls() { XCTAssertEqual( GhosttyApp.runtimeColorSchemeSynchronizationDecision( + applied: nil, + requested: GHOSTTY_COLOR_SCHEME_DARK, isSynchronizing: false ), .apply ) XCTAssertEqual( GhosttyApp.runtimeColorSchemeSynchronizationDecision( + applied: GHOSTTY_COLOR_SCHEME_DARK, + requested: GHOSTTY_COLOR_SCHEME_DARK, + isSynchronizing: false + ), + .apply + ) + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeSynchronizationDecision( + applied: GHOSTTY_COLOR_SCHEME_LIGHT, + requested: GHOSTTY_COLOR_SCHEME_DARK, isSynchronizing: true ), .skipReentrant ) } + func testRuntimeColorSchemeForCmuxSingleThemeReloadKeepsResolvedSchemeDuringConfigLoad() { + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeForConfigLoad( + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadFinalSource, + requestedColorScheme: .dark, + effectiveTerminalColorScheme: .light, + cmuxThemeValue: "light:3024 Day,dark:3024 Day" + ), + .light + ) + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeForConfigLoad( + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadPreviewSource, + requestedColorScheme: .dark, + effectiveTerminalColorScheme: .light, + cmuxThemeValue: "3024 Day" + ), + .light + ) + } + + func testRuntimeColorSchemeForPairedThemeReloadUsesAppearanceDuringConfigLoad() { + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeForConfigLoad( + source: GhosttySurfaceConfigurationRefresh.cmuxThemeReloadFinalSource, + requestedColorScheme: .dark, + effectiveTerminalColorScheme: .light, + cmuxThemeValue: "light:3024 Day,dark:3024 Night" + ), + .dark + ) + XCTAssertEqual( + GhosttyApp.runtimeColorSchemeForConfigLoad( + source: "socket.reload_config", + requestedColorScheme: .dark, + effectiveTerminalColorScheme: .light, + cmuxThemeValue: "light:3024 Day,dark:3024 Day" + ), + .dark + ) + } + func testScrollLagCaptureRequiresSustainedLag() { let cases: [(samples: Int, averageMs: Double, maxMs: Double, expected: Bool)] = [ (4, 18, 85, false), From 7eb16767da81e61b43ba513b9d05b7c3ca8be464 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 17:48:24 -0700 Subject: [PATCH 36/39] fix: target theme reload notifications by socket --- CLI/CMUXCLI+ThemeSupport.swift | 56 +++++++++++++- CLI/CMUXCLI+Themes.swift | 2 +- .../Windowing/WindowAppearanceSnapshot.swift | 5 +- .../CMUXCLIErrorOutputRegressionTests.swift | 75 +++++++++++++++++++ cmuxTests/WindowAppearanceSnapshotTests.swift | 33 ++++++++ 5 files changed, 166 insertions(+), 5 deletions(-) diff --git a/CLI/CMUXCLI+ThemeSupport.swift b/CLI/CMUXCLI+ThemeSupport.swift index 74181c119090..0487258ef195 100644 --- a/CLI/CMUXCLI+ThemeSupport.swift +++ b/CLI/CMUXCLI+ThemeSupport.swift @@ -310,21 +310,73 @@ extension CMUXCLI { } func reloadThemesIfPossible( - socketPath _: String, + socketPath: String, explicitPassword _: String? ) -> ThemeReloadStatus { - let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + let bundleIdentifier = themeReloadTargetBundleIdentifier(socketPath: socketPath) DistributedNotificationCenter.default().post( name: Notification.Name(Self.cmuxThemesReloadNotificationName), object: nil, userInfo: [ "bundleIdentifier": bundleIdentifier, + "socketPath": socketPath, "phase": "final", ] ) return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) } + func themeReloadTargetBundleIdentifier(socketPath: String) -> String { + bundleIdentifierForThemeReloadSocketPath(socketPath) + ?? currentCmuxAppBundleIdentifier() + ?? Self.cmuxThemeOverrideBundleIdentifier + } + + private func bundleIdentifierForThemeReloadSocketPath(_ socketPath: String) -> String? { + let name = URL(fileURLWithPath: socketPath).lastPathComponent + switch name { + case "cmux.sock": + return Self.cmuxThemeOverrideBundleIdentifier + case "cmux-debug.sock": + return "com.cmuxterm.app.debug" + case "cmux-nightly.sock": + return "com.cmuxterm.app.nightly" + case "cmux-staging.sock": + return "com.cmuxterm.app.staging" + default: + break + } + + if name.range(of: #"^cmux-\d+\.sock$"#, options: .regularExpression) != nil { + return Self.cmuxThemeOverrideBundleIdentifier + } + + if let slug = themeReloadSocketSlug(name, prefix: "cmux-debug-", suffix: ".sock") { + return "com.cmuxterm.app.debug.\(slug)" + } + if let slug = themeReloadSocketSlug(name, prefix: "cmux-nightly-", suffix: ".sock") { + return "com.cmuxterm.app.nightly.\(slug)" + } + if let slug = themeReloadSocketSlug(name, prefix: "cmux-staging-", suffix: ".sock") { + return "com.cmuxterm.app.staging.\(slug)" + } + return nil + } + + private func themeReloadSocketSlug(_ name: String, prefix: String, suffix: String) -> String? { + guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { + return nil + } + let start = name.index(name.startIndex, offsetBy: prefix.count) + let end = name.index(name.endIndex, offsetBy: -suffix.count) + let rawSlug = String(name[start.. String? { if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { diff --git a/CLI/CMUXCLI+Themes.swift b/CLI/CMUXCLI+Themes.swift index 903cc3272d8c..5555fc6b46e1 100644 --- a/CLI/CMUXCLI+Themes.swift +++ b/CLI/CMUXCLI+Themes.swift @@ -46,7 +46,7 @@ extension CMUXCLI { let selection = currentThemeSelection() var environment = ProcessInfo.processInfo.environment environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path - environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + environment["CMUX_THEME_PICKER_BUNDLE_ID"] = themeReloadTargetBundleIdentifier(socketPath: socketPath) environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" if let light = selection.light { diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index 3833885a940d..9e7e517dd7da 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -313,7 +313,8 @@ struct WindowAppearanceSnapshot { return nil } - let srgb = terminalBackgroundColor.usingColorSpace(.sRGB) ?? terminalBackgroundColor + let composited = compositedTerminalBackgroundColor + let srgb = composited.usingColorSpace(.sRGB) ?? composited var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 @@ -321,7 +322,7 @@ struct WindowAppearanceSnapshot { srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) _ = alpha - let isLight = terminalBackgroundColor.isLightColor + let isLight = composited.isLightColor let adjustment: CGFloat = isLight ? -0.05 : 0.07 return NSColor( srgbRed: min(1, max(0, red + adjustment)), diff --git a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift index b53c5e15b4a5..93f97b1edc02 100644 --- a/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +++ b/cmuxTests/CMUXCLIErrorOutputRegressionTests.swift @@ -215,6 +215,81 @@ final class CMUXCLIErrorOutputRegressionTests: XCTestCase { XCTAssertEqual(responder.receivedRequests, []) } + func testThemesSetTargetsResolvedTaggedSocketWhenBundleEnvironmentIsStale() throws { + let cliPath = try bundledCLIPath() + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-themes-stale-bundle-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let resourcesURL = root.appendingPathComponent("resources", isDirectory: true) + let themesURL = resourcesURL.appendingPathComponent("themes", isDirectory: true) + try fileManager.createDirectory(at: themesURL, withIntermediateDirectories: true) + try writeTheme(named: "Theme A", background: "#101010", to: themesURL) + + let socketPath = "/tmp/cmux-debug-active-theme.sock" + let staleBundleIdentifier = "com.cmuxterm.app.debug.stale.theme" + let targetBundleIdentifier = "com.cmuxterm.app.debug.active.theme" + let reloadExpectation = expectation(description: "cmux themes set targets the resolved socket bundle") + let notificationQueue = OperationQueue() + notificationQueue.maxConcurrentOperationCount = 1 + let notificationLock = NSLock() + var observedReloads: [(bundleIdentifier: String?, phase: String?, socketPath: String?)] = [] + let observer = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("com.cmuxterm.themes.reload-config"), + object: nil, + queue: notificationQueue + ) { notification in + let observedBundleIdentifier = notification.userInfo?["bundleIdentifier"] as? String + guard observedBundleIdentifier == targetBundleIdentifier else { return } + let observedPhase = notification.userInfo?["phase"] as? String + let observedSocketPath = notification.userInfo?["socketPath"] as? String + notificationLock.lock() + observedReloads.append(( + bundleIdentifier: observedBundleIdentifier, + phase: observedPhase, + socketPath: observedSocketPath + )) + notificationLock.unlock() + reloadExpectation.fulfill() + } + defer { + DistributedNotificationCenter.default().removeObserver(observer) + } + + var environment = ProcessInfo.processInfo.environment + for key in Array(environment.keys) where key.hasPrefix("CMUX_") { + environment.removeValue(forKey: key) + } + environment["CFFIXED_USER_HOME"] = root.path + environment["HOME"] = root.path + environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_BUNDLE_ID"] = staleBundleIdentifier + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["--json", "themes", "set", "Theme A"], + environment: environment, + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stdout) + XCTAssertEqual(result.status, 0, result.stdout) + wait(for: [reloadExpectation], timeout: 5) + + notificationLock.lock() + let reloads = observedReloads + notificationLock.unlock() + XCTAssertEqual(reloads.map { $0.bundleIdentifier }, [targetBundleIdentifier]) + XCTAssertEqual(reloads.map { $0.phase }, ["final"]) + XCTAssertEqual(reloads.map { $0.socketPath }, [socketPath]) + XCTAssertFalse(result.stdout.contains(staleBundleIdentifier), result.stdout) + XCTAssertTrue(result.stdout.contains(targetBundleIdentifier), result.stdout) + } + func testBareInteractiveThemesReloadsRunningAppAfterPickerExits() throws { let cliPath = try bundledCLIPath() let fileManager = FileManager.default diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index dec5001c4798..10218d241c90 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -159,6 +159,39 @@ final class WindowAppearanceSnapshotTests: XCTestCase { XCTAssertNil(makeSnapshot(unifySurfaceBackdrops: false).sidebarContrastOverlayColor(for: .leftSidebar)) } + func testUnifiedSidebarContrastOverlayUsesCompositedTerminalBackground() { + let snapshot = makeSnapshot( + unifySurfaceBackdrops: true, + backgroundHex: "#000000", + backgroundOpacity: 0.05 + ) + guard let overlay = snapshot.sidebarContrastOverlayColor(for: .leftSidebar), + let overlaySRGB = overlay.usingColorSpace(.sRGB), + let compositedSRGB = snapshot.compositedTerminalBackgroundColor.usingColorSpace(.sRGB) else { + XCTFail("expected sRGB-convertible sidebar overlay") + return + } + + let isLight = snapshot.compositedTerminalBackgroundColor.isLightColor + let adjustment: CGFloat = isLight ? -0.05 : 0.07 + XCTAssertEqual( + overlaySRGB.redComponent, + min(1, max(0, compositedSRGB.redComponent + adjustment)), + accuracy: 0.001 + ) + XCTAssertEqual( + overlaySRGB.greenComponent, + min(1, max(0, compositedSRGB.greenComponent + adjustment)), + accuracy: 0.001 + ) + XCTAssertEqual( + overlaySRGB.blueComponent, + min(1, max(0, compositedSRGB.blueComponent + adjustment)), + accuracy: 0.001 + ) + XCTAssertEqual(overlaySRGB.alphaComponent, isLight ? 0.20 : 0.18, accuracy: 0.0001) + } + func testOpaqueTerminalUsesOpaqueWindowFill() { let snapshot = makeSnapshot(unifySurfaceBackdrops: false, backgroundOpacity: 1.0) let plan = snapshot.backdropPlan(glassEffectAvailable: false) From 452745b65753953fed9d66cbb26878d6f755315b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 18:24:12 -0700 Subject: [PATCH 37/39] fix: align sidebar overlay contrast scheme --- .../Windowing/WindowAppearanceSnapshot.swift | 2 +- cmuxTests/WindowAppearanceSnapshotTests.swift | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index 9e7e517dd7da..ca10347da1c3 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -322,7 +322,7 @@ struct WindowAppearanceSnapshot { srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) _ = alpha - let isLight = composited.isLightColor + let isLight = cmuxReadableColorScheme(for: composited) == .light let adjustment: CGFloat = isLight ? -0.05 : 0.07 return NSColor( srgbRed: min(1, max(0, red + adjustment)), diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 10218d241c90..0091459dd615 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -172,7 +172,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { return } - let isLight = snapshot.compositedTerminalBackgroundColor.isLightColor + let isLight = cmuxReadableColorScheme(for: snapshot.compositedTerminalBackgroundColor) == .light let adjustment: CGFloat = isLight ? -0.05 : 0.07 XCTAssertEqual( overlaySRGB.redComponent, @@ -192,6 +192,29 @@ final class WindowAppearanceSnapshotTests: XCTestCase { XCTAssertEqual(overlaySRGB.alphaComponent, isLight ? 0.20 : 0.18, accuracy: 0.0001) } + func testUnifiedSidebarContrastOverlayUsesChromeReadableSchemeForMediumBackground() { + let snapshot = makeSnapshot( + unifySurfaceBackdrops: true, + backgroundHex: "#777777", + backgroundOpacity: 1 + ) + + XCTAssertFalse(snapshot.compositedTerminalBackgroundColor.isLightColor) + XCTAssertEqual(snapshot.chromeColorScheme, .light) + + guard let overlay = snapshot.sidebarContrastOverlayColor(for: .leftSidebar), + let overlaySRGB = overlay.usingColorSpace(.sRGB), + let compositedSRGB = snapshot.compositedTerminalBackgroundColor.usingColorSpace(.sRGB) else { + XCTFail("expected sRGB-convertible sidebar overlay") + return + } + + XCTAssertLessThan(overlaySRGB.redComponent, compositedSRGB.redComponent) + XCTAssertLessThan(overlaySRGB.greenComponent, compositedSRGB.greenComponent) + XCTAssertLessThan(overlaySRGB.blueComponent, compositedSRGB.blueComponent) + XCTAssertEqual(overlaySRGB.alphaComponent, 0.20, accuracy: 0.0001) + } + func testOpaqueTerminalUsesOpaqueWindowFill() { let snapshot = makeSnapshot(unifySurfaceBackdrops: false, backgroundOpacity: 1.0) let plan = snapshot.backdropPlan(glassEffectAvailable: false) From 1ca56296d6c47acbcb6edddb47e89bd159097e2e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 18:34:22 -0700 Subject: [PATCH 38/39] fix: preserve transparent terminal hosting --- Sources/Windowing/WindowBackdropController.swift | 4 ++-- cmuxTests/WindowAppearanceSnapshotTests.swift | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/Windowing/WindowBackdropController.swift b/Sources/Windowing/WindowBackdropController.swift index d9c8630f16a3..d4e3f9f46205 100644 --- a/Sources/Windowing/WindowBackdropController.swift +++ b/Sources/Windowing/WindowBackdropController.swift @@ -170,7 +170,7 @@ extension WindowAppearanceSnapshot { ) } - if compositedTerminalBackgroundColor.alphaComponent < 0.999 { + if terminalBackgroundOpacity < 0.999 { return WindowBackdropPlan( hostingPhase: .transparentRootBackdrop, windowBackgroundColor: cmuxTransparentWindowBaseColor(), @@ -184,7 +184,7 @@ extension WindowAppearanceSnapshot { return WindowBackdropPlan( hostingPhase: .opaqueWindowFill, windowBackgroundColor: compositedTerminalBackgroundColor, - windowIsOpaque: compositedTerminalBackgroundColor.alphaComponent >= 0.999, + windowIsOpaque: true, rootPolicy: rootPolicy, glass: nil, shouldApplyGhosttyCompositorBlur: false diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index 0091459dd615..752a49fee171 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -81,6 +81,19 @@ final class WindowAppearanceSnapshotTests: XCTestCase { XCTAssertEqual(sidebarPolicy.tintColor.hexString(includeAlpha: true), "#FF000066") } + func testTranslucentTerminalUsesTransparentHostingWithOpaqueCompositedChromeColor() { + let snapshot = makeSnapshot( + unifySurfaceBackdrops: true, + backgroundOpacity: 0.5 + ) + + XCTAssertEqual(snapshot.compositedTerminalBackgroundColor.alphaComponent, 1, accuracy: 0.0001) + + let plan = snapshot.backdropPlan(glassEffectAvailable: false) + XCTAssertEqual(plan.hostingPhase, .transparentRootBackdrop) + XCTAssertTrue(plan.usesTransparentWindow) + } + func testSidebarTintChangesDoNotDriveWindowBackdropPlanIdentity() { let red = makeSnapshot( unifySurfaceBackdrops: false, From aac800543a098f026091ab38e0bdf78e8beba5ff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 19:24:23 -0700 Subject: [PATCH 39/39] fix: preserve right sidebar remembered mode --- Sources/AppDelegate.swift | 1 + Sources/MainWindowFocusController.swift | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ce05327b2968..9566602aee6a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5908,6 +5908,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { state.setVisible(true) state.mode = mode + context?.keyboardFocusCoordinator.rememberRightSidebarMode(mode) } return .ok diff --git a/Sources/MainWindowFocusController.swift b/Sources/MainWindowFocusController.swift index 191b686690c6..98b1f9bc7b7d 100644 --- a/Sources/MainWindowFocusController.swift +++ b/Sources/MainWindowFocusController.swift @@ -136,6 +136,14 @@ final class MainWindowFocusController { publishFeedFocusSnapshot() } + func rememberRightSidebarMode(_ mode: RightSidebarMode) { + rememberedRightSidebarMode = mode + if mode != .feed { + feedSelectedItemId = nil + } + publishFeedFocusSnapshot() + } + func noteTerminalInteraction(workspaceId: UUID, panelId: UUID) { noteMainPanelInteraction(workspaceId: workspaceId, panelId: panelId) }