From 6584a01aef418d6cad187a5e0d45096586941518 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 03:26:51 -0700 Subject: [PATCH 01/12] Add cmux themes command --- CLI/cmux.swift | 561 ++++++++++++++++++++++++++++++ Sources/AppDelegate.swift | 21 ++ Sources/GhosttyConfig.swift | 49 ++- Sources/GhosttyTerminalView.swift | 90 ++--- Sources/TerminalController.swift | 22 ++ 5 files changed, 697 insertions(+), 46 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8346d1ab..75f64401 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -934,6 +934,14 @@ struct CMUXCLI { return } + if command == "themes" { + try runThemes( + commandArgs: commandArgs, + jsonOutput: jsonOutput + ) + return + } + if command == "claude-teams" { try runClaudeTeams( commandArgs: commandArgs, @@ -4264,6 +4272,31 @@ struct CMUXCLI { Double check with the end user before sending anything. Review the message and attachments for secrets, private code, credentials, tokens, and other sensitive information first. """ + case "themes": + return """ + Usage: cmux themes + cmux themes list + cmux themes set + cmux themes set --light [--dark ] + cmux themes set --dark [--light ] + cmux themes clear + + List bundled and discovered Ghostty themes, show the current cmux light/dark + theme defaults, and update the default theme override used by cmux. + + Commands: + list List available themes and mark the current light/dark defaults + set Set the same theme for both light and dark appearance + set --light Set the light appearance theme + set --dark Set the dark appearance theme + clear Remove the cmux theme override and fall back to other config + + Examples: + cmux themes + cmux themes set "Catppuccin Mocha" + cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" + cmux themes clear + """ case "claude-teams": return String(localized: "cli.claude-teams.usage", defaultValue: """ Usage: cmux claude-teams [claude-args...] @@ -5330,6 +5363,533 @@ struct CMUXCLI { return true } + private static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app" + private static let cmuxThemesBlockStart = "# cmux themes start" + private static let cmuxThemesBlockEnd = "# cmux themes end" + private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config" + + private struct ThemeSelection { + let rawValue: String? + let light: String? + let dark: String? + let sourcePath: String? + } + + private struct ThemeReloadStatus { + let requested: Bool + let targetBundleIdentifier: String + } + + private func runThemes(commandArgs: [String], jsonOutput: Bool) throws { + if commandArgs.isEmpty { + try printThemesList(jsonOutput: jsonOutput) + return + } + + guard let subcommand = commandArgs.first else { + try printThemesList(jsonOutput: jsonOutput) + return + } + + switch subcommand { + case "list": + if commandArgs.count > 1 { + throw CLIError(message: "themes list does not take any positional arguments") + } + try printThemesList(jsonOutput: jsonOutput) + case "set": + try runThemesSet( + args: Array(commandArgs.dropFirst()), + jsonOutput: jsonOutput + ) + case "clear": + if commandArgs.count > 1 { + throw CLIError(message: "themes clear does not take any positional arguments") + } + try runThemesClear(jsonOutput: jsonOutput) + default: + if subcommand.hasPrefix("-") { + throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.") + } + + try runThemesSet( + args: commandArgs, + jsonOutput: jsonOutput + ) + } + } + + private func printThemesList(jsonOutput: Bool) throws { + let themes = availableThemeNames() + let current = currentThemeSelection() + let configPath = try cmuxThemeOverrideConfigURL().path + + if jsonOutput { + let currentPayload: [String: Any] = [ + "raw_value": current.rawValue ?? NSNull(), + "light": current.light ?? NSNull(), + "dark": current.dark ?? NSNull(), + "source_path": current.sourcePath ?? NSNull() + ] + let payload: [String: Any] = [ + "themes": themes.map { theme in + [ + "name": theme, + "current_light": current.light?.caseInsensitiveCompare(theme) == .orderedSame, + "current_dark": current.dark?.caseInsensitiveCompare(theme) == .orderedSame + ] + }, + "current": currentPayload, + "config_path": configPath + ] + print(jsonString(payload)) + return + } + + print("Current light: \(current.light ?? "inherit")") + print("Current dark: \(current.dark ?? "inherit")") + print("Config: \(configPath)") + if let sourcePath = current.sourcePath { + print("Source: \(sourcePath)") + } + print("") + + guard !themes.isEmpty else { + print("No themes found.") + return + } + + for theme in themes { + var badges: [String] = [] + if current.light?.caseInsensitiveCompare(theme) == .orderedSame { + badges.append("light") + } + if current.dark?.caseInsensitiveCompare(theme) == .orderedSame { + badges.append("dark") + } + let badgeText = badges.isEmpty ? "" : " [\(badges.joined(separator: ", "))]" + print("\(theme)\(badgeText)") + } + } + + private func runThemesSet(args: [String], jsonOutput: Bool) throws { + let (lightOpt, rem0) = parseOption(args, name: "--light") + let (darkOpt, rem1) = parseOption(rem0, name: "--dark") + + if let unknown = rem1.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light , --dark ") + } + + let availableThemes = availableThemeNames() + let current = currentThemeSelection() + + let lightTheme: String? + let darkTheme: String? + + if lightOpt == nil && darkOpt == nil { + let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !joinedTheme.isEmpty else { + throw CLIError(message: "themes set requires a theme name or --light/--dark flags") + } + let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes) + lightTheme = resolved + darkTheme = resolved + } else { + if !rem1.isEmpty { + throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'") + } + lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light + darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark + } + + guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else { + throw CLIError(message: "themes set requires at least one theme") + } + + let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue) + let reloadStatus = reloadThemesIfPossible() + + if jsonOutput { + let payload: [String: Any] = [ + "ok": true, + "light": lightTheme ?? NSNull(), + "dark": darkTheme ?? NSNull(), + "raw_value": rawThemeValue, + "config_path": configURL.path, + "reload_requested": reloadStatus.requested, + "reload_target_bundle_id": reloadStatus.targetBundleIdentifier + ] + print(jsonString(payload)) + return + } + + print( + "OK light=\(lightTheme ?? "-") dark=\(darkTheme ?? "-") config=\(configURL.path) reload=requested" + ) + } + + private func runThemesClear(jsonOutput: Bool) throws { + let configURL = try clearManagedThemeOverride() + let reloadStatus = reloadThemesIfPossible() + + if jsonOutput { + let payload: [String: Any] = [ + "ok": true, + "cleared": true, + "config_path": configURL.path, + "reload_requested": reloadStatus.requested, + "reload_target_bundle_id": reloadStatus.targetBundleIdentifier + ] + print(jsonString(payload)) + return + } + + print("OK cleared config=\(configURL.path) reload=requested") + } + + private func currentThemeSelection() -> ThemeSelection { + var rawValue: String? + var sourcePath: String? + + for url in themeConfigSearchURLs() { + guard let contents = try? String(contentsOf: url, encoding: .utf8), + let nextValue = lastThemeDirective(in: contents) else { + continue + } + rawValue = nextValue + sourcePath = url.path + } + + return parseThemeSelection(rawValue: rawValue, sourcePath: sourcePath) + } + + private func parseThemeSelection(rawValue: String?, sourcePath: String?) -> ThemeSelection { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { + return ThemeSelection(rawValue: nil, light: nil, dark: nil, sourcePath: sourcePath) + } + + var fallbackTheme: String? + var lightTheme: String? + var darkTheme: String? + + for token in rawValue.split(separator: ",").map(String.init) { + let entry = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !entry.isEmpty else { continue } + + let parts = entry.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count != 2 { + if fallbackTheme == nil { + fallbackTheme = entry + } + continue + } + + let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + + switch key { + case "light": + if lightTheme == nil { + lightTheme = value + } + case "dark": + if darkTheme == nil { + darkTheme = value + } + default: + if fallbackTheme == nil { + fallbackTheme = value + } + } + } + + let resolvedLight = lightTheme ?? fallbackTheme ?? darkTheme + let resolvedDark = darkTheme ?? fallbackTheme ?? lightTheme + return ThemeSelection(rawValue: rawValue, light: resolvedLight, dark: resolvedDark, sourcePath: sourcePath) + } + + private func encodedThemeValue(light: String?, dark: String?) -> String? { + let normalizedLight = light?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDark = dark?.trimmingCharacters(in: .whitespacesAndNewlines) + + switch (normalizedLight?.isEmpty == false ? normalizedLight : nil, normalizedDark?.isEmpty == false ? normalizedDark : nil) { + case let (lightTheme?, darkTheme?): + return "light:\(lightTheme),dark:\(darkTheme)" + case let (lightTheme?, nil): + return "light:\(lightTheme)" + case let (nil, darkTheme?): + return "dark:\(darkTheme)" + case (nil, nil): + return nil + } + } + + private func availableThemeNames() -> [String] { + let fileManager = FileManager.default + var seen: Set = [] + var themes: [String] = [] + + for directoryURL in themeDirectoryURLs() { + guard let entries = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + + for entry in entries { + let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + guard values?.isDirectory != true else { continue } + guard values?.isRegularFile == true || values?.isRegularFile == nil else { continue } + let name = entry.lastPathComponent + let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + if seen.insert(folded).inserted { + themes.append(name) + } + } + } + + return themes.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + + private func themeDirectoryURLs() -> [URL] { + let fileManager = FileManager.default + let processEnv = ProcessInfo.processInfo.environment + var urls: [URL] = [] + var seen: Set = [] + + func appendIfExisting(_ url: URL?) { + guard let url else { return } + let standardized = url.standardizedFileURL + guard fileManager.fileExists(atPath: standardized.path) else { return } + if seen.insert(standardized.path).inserted { + urls.append(standardized) + } + } + + if let resourcesDir = processEnv["GHOSTTY_RESOURCES_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !resourcesDir.isEmpty { + appendIfExisting(URL(fileURLWithPath: resourcesDir, isDirectory: true).appendingPathComponent("themes", isDirectory: true)) + } + + appendIfExisting( + Bundle.main.resourceURL? + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + + if let executableURL = resolvedExecutableURL() { + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Resources" { + appendIfExisting( + current + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + } + if current.lastPathComponent == "Contents" { + appendIfExisting( + current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + let repoThemes = current.appendingPathComponent("Resources/ghostty/themes", isDirectory: true) + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoThemes.path) { + appendIfExisting(repoThemes) + break + } + + guard let parent = parentSearchURL(for: current) else { break } + current = parent + } + } + + if let xdgDataDirs = processEnv["XDG_DATA_DIRS"] { + for dataDir in xdgDataDirs.split(separator: ":").map(String.init).filter({ !$0.isEmpty }) { + appendIfExisting( + URL(fileURLWithPath: NSString(string: dataDir).expandingTildeInPath, isDirectory: true) + .appendingPathComponent("ghostty/themes", isDirectory: true) + ) + } + } + + appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true)) + appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true)) + appendIfExisting( + URL( + fileURLWithPath: NSString( + string: "~/Library/Application Support/com.mitchellh.ghostty/themes" + ).expandingTildeInPath, + isDirectory: true + ) + ) + + return urls + } + + private func validatedThemeName(_ rawValue: String, availableThemes: [String]) throws -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CLIError(message: "Theme name cannot be empty") + } + if let matched = availableThemes.first(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + return matched + } + if availableThemes.isEmpty { + return trimmed + } + throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.") + } + + private func themeConfigSearchURLs() -> [URL] { + let rawPaths = [ + "~/.config/ghostty/config", + "~/.config/ghostty/config.ghostty", + "~/Library/Application Support/com.mitchellh.ghostty/config", + "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", + "~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config", + "~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty", + ] + + return rawPaths.map { + URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: false) + } + } + + private 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 + } + + private func cmuxThemeOverrideConfigURL() throws -> URL { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw CLIError(message: "Unable to resolve Application Support directory") + } + return appSupport + .appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true) + .appendingPathComponent("config.ghostty", isDirectory: false) + } + + private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL { + let fileManager = FileManager.default + let configURL = try cmuxThemeOverrideConfigURL() + let directoryURL = configURL.deletingLastPathComponent() + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + + let existingContents = (try? String(contentsOf: configURL, encoding: .utf8)) ?? "" + let strippedContents = removingManagedThemeOverride(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + let block = """ + \(Self.cmuxThemesBlockStart) + theme = \(rawThemeValue) + \(Self.cmuxThemesBlockEnd) + """ + + let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n" + try nextContents.write(to: configURL, atomically: true, encoding: .utf8) + return configURL + } + + private func clearManagedThemeOverride() throws -> URL { + let fileManager = FileManager.default + let configURL = try cmuxThemeOverrideConfigURL() + guard let existingContents = try? String(contentsOf: configURL, encoding: .utf8) else { + return configURL + } + + let strippedContents = removingManagedThemeOverride(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if strippedContents.isEmpty { + try? fileManager.removeItem(at: configURL) + } else { + try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8) + } + + return configURL + } + + private func removingManagedThemeOverride(from contents: String) -> String { + let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"# + guard let range = contents.range(of: pattern, options: [.regularExpression]) else { + return contents + } + return contents.replacingCharacters(in: range, with: "") + } + + private func reloadThemesIfPossible() -> ThemeReloadStatus { + let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + DistributedNotificationCenter.default().post( + name: Notification.Name(Self.cmuxThemesReloadNotificationName), + object: bundleIdentifier, + userInfo: nil + ) + return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) + } + + private func currentCmuxAppBundleIdentifier() -> String? { + if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + guard let executableURL = resolvedExecutableURL() else { + return nil + } + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.pathExtension == "app", + let bundleIdentifier = Bundle(url: current)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + if current.lastPathComponent == "Contents" { + let appURL = current.deletingLastPathComponent().standardizedFileURL + if appURL.pathExtension == "app", + let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + } + + guard let parent = parentSearchURL(for: current) else { + break + } + current = parent + } + + return nil + } + /// Escape and quote a string for safe embedding in a v1 socket command. /// The socket tokenizer treats `\` and `"` as special inside quoted strings, /// so both must be escaped before wrapping in double quotes. Newlines and @@ -8398,6 +8958,7 @@ struct CMUXCLI { welcome shortcuts feedback [--email --body [--image ...]] + themes [list|set|clear] claude-teams [claude-args...] ping capabilities diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c..9c4a55ba 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,10 @@ import Combine import ObjectiveC.runtime import Darwin +private enum CmuxThemeNotifications { + static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") +} + #if DEBUG enum CmuxTypingTiming { static let isEnabled: Bool = { @@ -2118,6 +2122,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isRunningUnderXCTest = isRunningUnderXCTest(env) let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleThemesReloadNotification(_:)), + name: CmuxThemeNotifications.reloadConfig, + object: Bundle.main.bundleIdentifier, + suspensionBehavior: .deliverImmediately + ) + #if DEBUG // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make // key-equivalent routing flaky. Force defaults for deterministic tests. @@ -10766,6 +10778,14 @@ private extension NSApplication { } } +private extension AppDelegate { + @objc func handleThemesReloadNotification(_ notification: Notification) { + DispatchQueue.main.async { + GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") + } + } +} + private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { if cmuxIsWindowFirstResponderBypassActive() { @@ -11357,4 +11377,5 @@ private extension NSWindow { } return hitWebView === webView } + } diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index aff57538..370572ae 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -7,6 +7,7 @@ struct GhosttyConfig { case dark } + private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app" private static let loadCacheLock = NSLock() private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:] @@ -87,6 +88,52 @@ struct GhosttyConfig { loadCacheLock.unlock() } + private static func cmuxConfigPaths( + fileManager: FileManager = .default, + currentBundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> [String] { + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return [] + } + + func paths(for bundleIdentifier: String) -> [String] { + let directory = appSupport.appendingPathComponent(bundleIdentifier, isDirectory: true) + return [ + directory.appendingPathComponent("config", isDirectory: false).path, + directory.appendingPathComponent("config.ghostty", isDirectory: false).path, + ] + } + + func hasConfig(_ paths: [String]) -> Bool { + paths.contains { path in + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + let type = attributes[.type] as? FileAttributeType, + type == .typeRegular, + let size = attributes[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + } + + let releasePaths = paths(for: cmuxReleaseBundleIdentifier) + guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { + return releasePaths + } + if currentBundleIdentifier == cmuxReleaseBundleIdentifier { + return releasePaths + } + + let currentPaths = paths(for: currentBundleIdentifier) + if hasConfig(currentPaths) { + return currentPaths + } + if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { + return releasePaths + } + return [] + } + private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig { var config = GhosttyConfig() @@ -96,7 +143,7 @@ struct GhosttyConfig { "~/.config/ghostty/config.ghostty", "~/Library/Application Support/com.mitchellh.ghostty/config", "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", - ].map { NSString(string: $0).expandingTildeInPath } + ].map { NSString(string: $0).expandingTildeInPath } + cmuxConfigPaths() for path in configPaths { if let contents = readConfigFile(at: path) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 65e50eaa..8eca2e02 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1026,9 +1026,9 @@ class GhosttyApp { private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) - loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) + loadCmuxAppSupportGhosttyConfigIfNeeded(config) loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) } @@ -1229,20 +1229,44 @@ class GhosttyApp { return true } - static func shouldLoadReleaseAppSupportGhosttyConfig( + static func cmuxAppSupportConfigURLs( currentBundleIdentifier: String?, - currentConfigFileSize: Int?, - currentLegacyConfigFileSize: Int?, - releaseConfigFileSize: Int?, - releaseLegacyConfigFileSize: Int? - ) -> Bool { - guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false } + appSupportDirectory: URL, + fileManager: FileManager = .default + ) -> [URL] { + guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] } - let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0 - guard !hasCurrentAppSupportConfig else { return false } + func configURLs(for bundleIdentifier: String) -> [URL] { + let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true) + return [ + directory.appendingPathComponent("config", isDirectory: false), + directory.appendingPathComponent("config.ghostty", isDirectory: false) + ] + } - let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0 - return hasReleaseAppSupportConfig + func hasConfig(_ urls: [URL]) -> Bool { + urls.contains { url in + guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), + let type = attrs[.type] as? FileAttributeType, + type == .typeRegular, + let size = attrs[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + } + + let currentURLs = configURLs(for: currentBundleIdentifier) + if hasConfig(currentURLs) { + return currentURLs + } + if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { + let releaseURLs = configURLs(for: releaseBundleIdentifier) + if hasConfig(releaseURLs) { + return releaseURLs + } + } + return [] } static func shouldApplyDefaultBackgroundUpdate( @@ -1282,52 +1306,28 @@ class GhosttyApp { return true } - private func loadReleaseAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { + private func loadCmuxAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } guard let currentBundleIdentifier = Bundle.main.bundleIdentifier, !currentBundleIdentifier.isEmpty else { return } - - let currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true) - let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true) - let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) - let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false) - let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) - let releaseLegacyConfig = releaseAppSupportDir.appendingPathComponent("config", isDirectory: false) - - func fileSize(_ url: URL) -> Int? { - guard let attrs = try? fm.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? NSNumber else { return nil } - return size.intValue - } - - let releaseConfigSize = fileSize(releaseConfig) - let releaseLegacyConfigSize = fileSize(releaseLegacyConfig) - - guard Self.shouldLoadReleaseAppSupportGhosttyConfig( + let urls = Self.cmuxAppSupportConfigURLs( currentBundleIdentifier: currentBundleIdentifier, - currentConfigFileSize: fileSize(currentConfig), - currentLegacyConfigFileSize: fileSize(currentLegacyConfig), - releaseConfigFileSize: releaseConfigSize, - releaseLegacyConfigFileSize: releaseLegacyConfigSize - ) else { return } + appSupportDirectory: appSupport, + fileManager: fm + ) + guard !urls.isEmpty else { return } - if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 { - releaseLegacyConfig.path.withCString { path in - ghostty_config_load_file(config, path) - } - } - - if let releaseConfigSize, releaseConfigSize > 0 { - releaseConfig.path.withCString { path in + for url in urls { + url.path.withCString { path in ghostty_config_load_file(config, path) } } #if DEBUG Self.initLog( - "loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)" + "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" ) #endif #endif diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bf4862c6..9a430bc0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1619,6 +1619,9 @@ class TerminalController { case "close_surface": return closeSurface(args) + case "reload_config": + return reloadConfig(args) + case "refresh_surfaces": return refreshSurfaces() @@ -9661,6 +9664,7 @@ class TerminalController { focus_pane - Focus a pane focus_surface_by_panel - Focus surface by panel ID close_surface [id|idx] - Close surface (collapse split) + reload_config [soft] - Reload Ghostty config and refresh terminals refresh_surfaces - Force refresh all terminals surface_health [workspace] - Check view health of all surfaces @@ -13820,6 +13824,24 @@ class TerminalController { return result } + private func reloadConfig(_ args: String) -> String { + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let soft: Bool + switch trimmed { + case "", "full": + soft = false + case "soft": + soft = true + default: + return "ERROR: Usage: reload_config [soft]" + } + + v2MainSync { + GhosttyApp.shared.reloadConfiguration(soft: soft, source: "socket.reload_config") + } + return soft ? "OK Reloaded config (soft)" : "OK Reloaded config" + } + private func refreshSurfaces() -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" } From ae24dad89f7237777d78e2a78f3c0ec926961b94 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:24:19 -0700 Subject: [PATCH 02/12] Use Ghostty theme picker for cmux themes --- CLI/cmux.swift | 145 ++++++++++++++++++++++++++++++++++++++++++- docs/ghostty-fork.md | 19 ++++++ ghostty | 2 +- scripts/reload.sh | 10 +++ 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 75f64401..0326b887 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4281,8 +4281,11 @@ struct CMUXCLI { cmux themes set --dark [--light ] cmux themes clear - List bundled and discovered Ghostty themes, show the current cmux light/dark - theme defaults, and update the default theme override used by cmux. + When run in a TTY, `cmux themes` opens an interactive theme picker with + live app preview. Use `cmux themes list` for a plain listing. + + The picker previews the selected theme across the running cmux app and + lets you apply it to the light theme, dark theme, or both defaults. Commands: list List available themes and mark the current light/dark defaults @@ -4293,6 +4296,7 @@ struct CMUXCLI { Examples: cmux themes + cmux themes list cmux themes set "Catppuccin Mocha" cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" cmux themes clear @@ -5380,8 +5384,145 @@ struct CMUXCLI { let targetBundleIdentifier: String } + private enum ThemePickerTargetMode: String { + case both + case light + case dark + } + + private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool { + guard !jsonOutput else { return false } + return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 + } + + private func runInteractiveThemes() throws { + guard let helperURL = bundledHelperURL(named: "ghostty") else { + throw CLIError(message: "Bundled Ghostty theme picker helper not found") + } + + 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_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue + if let light = selection.light { + environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light + } + if let dark = selection.dark { + environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark + } + if let resourcesURL = bundledGhosttyResourcesURL() { + environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path + } + + let process = Process() + process.executableURL = helperURL + process.arguments = ["+list-themes"] + process.environment = environment + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + try process.run() + process.waitUntilExit() + + guard process.terminationReason == .exit else { + throw CLIError(message: "Interactive theme picker terminated unexpectedly") + } + guard process.terminationStatus == 0 else { + throw CLIError(message: "Interactive theme picker failed with status \(process.terminationStatus)") + } + } + + private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { + if let light = current.light, + let dark = current.dark, + light.caseInsensitiveCompare(dark) == .orderedSame { + return .both + } + return defaultAppearancePrefersDarkThemes() ? .dark : .light + } + + private func defaultAppearancePrefersDarkThemes() -> Bool { + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) + let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame + } + + private func bundledHelperURL(named helperName: String) -> URL? { + let fileManager = FileManager.default + guard let executableURL = resolvedExecutableURL() else { return nil } + + var candidates: [URL] = [ + executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false) + ] + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Contents" { + candidates.append( + current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent(helperName, isDirectory: false) + ) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + let repoHelper = current + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("zig-out", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent(helperName, isDirectory: false) + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.isExecutableFile(atPath: repoHelper.path) { + candidates.append(repoHelper) + break + } + + guard let parent = parentSearchURL(for: current) else { break } + current = parent + } + + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) + } + + private func bundledGhosttyResourcesURL() -> URL? { + let fileManager = FileManager.default + guard let executableURL = resolvedExecutableURL() else { return nil } + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Contents" { + let candidate = current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + if fileManager.fileExists(atPath: candidate.path) { + return candidate + } + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + let repoResources = current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoResources.path) { + return repoResources + } + + guard let parent = parentSearchURL(for: current) else { break } + current = parent + } + + return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true) + } + private func runThemes(commandArgs: [String], jsonOutput: Bool) throws { if commandArgs.isEmpty { + if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { + try runInteractiveThemes() + return + } try printThemesList(jsonOutput: jsonOutput) return } diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index d85ca46b..0797f4be 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -86,6 +86,20 @@ touch the same stale-frame mitigation path and tend to conflict in the same file The fork branch HEAD is now the section 6 zsh redraw commit. +### 7) cmux theme picker helper hooks + +- Commit: `0c52c987b` (Add cmux theme picker helper hooks) +- Files: + - `build.zig` + - `src/cli/list_themes.zig` + - `src/main_ghostty.zig` +- Summary: + - Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS. + - Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview. + - Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain. + +The fork branch HEAD is now the section 7 cmux theme picker helper commit. + ## Upstreamed fork changes ### cursor-click-to-move respects OSC 133 click-to-move @@ -109,4 +123,9 @@ These files change frequently upstream; be careful when rebasing the fork: `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}` prompt newlines should not get an extra explicit continuation marker after the hidden CR. +- `src/cli/list_themes.zig` + - cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore. + If upstream reorganizes the preview loop or key handling, re-check the cmux mode path and keep the + stock Ghostty behavior unchanged when the cmux env vars are absent. + If you resolve a conflict, update this doc with what changed. diff --git a/ghostty b/ghostty index 312c7b23..0c52c987 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 312c7b23a7c8dc0704431940d76ba5dc32a46afb +Subproject commit 0c52c987be769f244c740b305033a2ec9d85b982 diff --git a/scripts/reload.sh b/scripts/reload.sh index 4e758a88..9c8df452 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -306,15 +306,25 @@ else fi sleep 0.3 CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd" +GHOSTTY_HELPER_SRC="$PWD/ghostty/zig-out/bin/ghostty" if [[ -d "$PWD/cmuxd" ]]; then (cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast) fi +if [[ -d "$PWD/ghostty" ]]; then + (cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast) +fi if [[ -x "$CMUXD_SRC" ]]; then BIN_DIR="$APP_PATH/Contents/Resources/bin" mkdir -p "$BIN_DIR" cp "$CMUXD_SRC" "$BIN_DIR/cmuxd" chmod +x "$BIN_DIR/cmuxd" fi +if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then + BIN_DIR="$APP_PATH/Contents/Resources/bin" + mkdir -p "$BIN_DIR" + cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty" + chmod +x "$BIN_DIR/ghostty" +fi CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [[ -x "$CLI_PATH" ]]; then echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true From 7d178ad5c7c0d98d55a43b49bd8dbfe550115d69 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:32:52 -0700 Subject: [PATCH 03/12] Fix interactive cmux themes launch --- CLI/cmux.swift | 48 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0326b887..7d8a0924 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5415,22 +5415,11 @@ struct CMUXCLI { environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path } - let process = Process() - process.executableURL = helperURL - process.arguments = ["+list-themes"] - process.environment = environment - process.standardInput = FileHandle.standardInput - process.standardOutput = FileHandle.standardOutput - process.standardError = FileHandle.standardError - try process.run() - process.waitUntilExit() - - guard process.terminationReason == .exit else { - throw CLIError(message: "Interactive theme picker terminated unexpectedly") - } - guard process.terminationStatus == 0 else { - throw CLIError(message: "Interactive theme picker failed with status \(process.terminationStatus)") - } + try execInteractiveHelper( + executablePath: helperURL.path, + arguments: ["+list-themes"], + environment: environment + ) } private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { @@ -5486,6 +5475,33 @@ struct CMUXCLI { return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) } + private func execInteractiveHelper( + executablePath: String, + arguments: [String], + environment: [String: String] + ) throws -> Never { + var argv = ([executablePath] + arguments).map { strdup($0) } + defer { + for item in argv { + free(item) + } + } + argv.append(nil) + + var envp = environment + .map { key, value in strdup("\(key)=\(value)") } + defer { + for item in envp { + free(item) + } + } + envp.append(nil) + + execve(executablePath, &argv, &envp) + let code = errno + throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))") + } + private func bundledGhosttyResourcesURL() -> URL? { let fileManager = FileManager.default guard let executableURL = resolvedExecutableURL() else { return nil } From 69a286bf5724a7dbede4f6937997cb8b2f54560d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:37:55 -0700 Subject: [PATCH 04/12] Fix cmux theme picker preview --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 0c52c987..78b30324 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 0c52c987be769f244c740b305033a2ec9d85b982 +Subproject commit 78b30324c2980cbcdd01d966ee6cf5e14c175998 From 48f656a7b9e95931901d9bb4fa03ad8453d16a58 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:44:49 -0700 Subject: [PATCH 05/12] Improve cmux theme picker footer contrast --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 78b30324..a8f08b0b 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 78b30324c2980cbcdd01d966ee6cf5e14c175998 +Subproject commit a8f08b0bf0a263c6b84b4df69673d9ba8c93dd06 From 1309152bfc4d8cf3b222c95a8bca3418b9494236 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:52:23 -0700 Subject: [PATCH 06/12] Respect system theme in cmux picker --- CLI/cmux.swift | 1 + ghostty | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7d8a0924..29b5608b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5405,6 +5405,7 @@ struct CMUXCLI { environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue + environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" if let light = selection.light { environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light } diff --git a/ghostty b/ghostty index a8f08b0b..2ba8dfac 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit a8f08b0bf0a263c6b84b4df69673d9ba8c93dd06 +Subproject commit 2ba8dfac27051c6566ceded486a1ea2af4b07f03 From cf979acbeb8142336d31a92d578fd337e607a939 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 04:56:27 -0700 Subject: [PATCH 07/12] Skip theme detection in cmux picker --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 2ba8dfac..a5afe9a4 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 2ba8dfac27051c6566ceded486a1ea2af4b07f03 +Subproject commit a5afe9a4a8c8cf05f82d7ba7f8f50af731cb75f1 From 6d955a6f404bd6504f392da99c077a15431a85a1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 05:12:38 -0700 Subject: [PATCH 08/12] Match Ghostty theme picker startup --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index a5afe9a4..3ec087ad 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit a5afe9a4a8c8cf05f82d7ba7f8f50af731cb75f1 +Subproject commit 3ec087ad3481e61df170373c20f07162154abb29 From e1f6d24655e5e205b6934c69a03bcac918992add Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 07:00:05 -0700 Subject: [PATCH 09/12] Harden cmux theme override writes --- CLI/cmux.swift | 5 +++-- ghostty | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 29b5608b..cc3b3faa 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5996,10 +5996,11 @@ struct CMUXCLI { private func removingManagedThemeOverride(from contents: String) -> String { let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"# - guard let range = contents.range(of: pattern, options: [.regularExpression]) else { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return contents } - return contents.replacingCharacters(in: range, with: "") + let fullRange = NSRange(contents.startIndex.. ThemeReloadStatus { diff --git a/ghostty b/ghostty index 3ec087ad..80cca8a1 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 3ec087ad3481e61df170373c20f07162154abb29 +Subproject commit 80cca8a12ebd554953fc6b35235135a3e61fe20c From 5f074f810eca42f6e7623c90fa29c5b708041d21 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 07:20:18 -0700 Subject: [PATCH 10/12] Fix session restore replay for transient terminal states --- Sources/GhosttyTerminalView.swift | 11 ++++++++--- Sources/Panels/TerminalPanel.swift | 6 ++++++ Sources/Workspace.swift | 18 ++++++++++++------ cmuxTests/SessionPersistenceTests.swift | 10 ++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8eca2e02..ff8e3d32 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2333,7 +2333,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? - private let additionalEnvironment: [String: String] + private var additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -2851,8 +2851,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if !additionalEnvironment.isEmpty { - for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { + let startupEnvironment = additionalEnvironment + if !startupEnvironment.isEmpty { + for (key, value) in startupEnvironment where !key.isEmpty && !value.isEmpty { env[key] = value } } @@ -2911,6 +2912,10 @@ final class TerminalSurface: Identifiable, ObservableObject { } guard let createdSurface = surface else { return } + // Session scrollback replay must be one-shot. Reusing it on a later runtime + // surface recreation would inject stale restored output into a live shell. + additionalEnvironment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey) + // For vsync-driven rendering, Ghostty needs to know which display we're on so it can // start a CVDisplayLink with the right refresh rate. If we don't set this early, the // renderer can believe vsync is "running" but never deliver frames, which looks like a diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index f9d197a3..3bf394fe 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -186,6 +186,12 @@ final class TerminalPanel: Panel, ObservableObject { surface.needsConfirmClose() } + func shouldPersistScrollbackForSessionSnapshot() -> Bool { + // Session restore only replays terminal output into a fresh shell. If Ghostty + // says we are not safely at a prompt, replaying that state later is misleading. + !surface.needsConfirmClose() + } + func triggerFlash() { hostedView.triggerFlash() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1bc7e1ed..49167da8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -311,7 +311,8 @@ extension Workspace { switch panel.panelType { case .terminal: guard let terminalPanel = panel as? TerminalPanel else { return nil } - let capturedScrollback = includeScrollback + let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot() + let capturedScrollback = includeScrollback && shouldPersistScrollback ? TerminalController.shared.readTerminalTextForSnapshot( terminalPanel: terminalPanel, includeScrollback: true, @@ -321,7 +322,8 @@ extension Workspace { let resolvedScrollback = terminalSnapshotScrollback( panelId: panelId, capturedScrollback: capturedScrollback, - includeScrollback: includeScrollback + includeScrollback: includeScrollback, + allowFallbackScrollback: shouldPersistScrollback ) terminalSnapshot = SessionTerminalPanelSnapshot( workingDirectory: panelDirectories[panelId], @@ -368,24 +370,28 @@ extension Workspace { nonisolated static func resolvedSnapshotTerminalScrollback( capturedScrollback: String?, - fallbackScrollback: String? + fallbackScrollback: String?, + allowFallbackScrollback: Bool = true ) -> String? { if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { return captured } + guard allowFallbackScrollback else { return nil } return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) } private func terminalSnapshotScrollback( panelId: UUID, capturedScrollback: String?, - includeScrollback: Bool + includeScrollback: Bool, + allowFallbackScrollback: Bool = true ) -> String? { guard includeScrollback else { return nil } - let fallback = restoredTerminalScrollbackByPanelId[panelId] + let fallback = allowFallbackScrollback ? restoredTerminalScrollbackByPanelId[panelId] : nil let resolved = Self.resolvedSnapshotTerminalScrollback( capturedScrollback: capturedScrollback, - fallbackScrollback: fallback + fallbackScrollback: fallback, + allowFallbackScrollback: allowFallbackScrollback ) if let resolved { restoredTerminalScrollbackByPanelId[panelId] = resolved diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 88d8f11c..af9ccf2d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -694,6 +694,16 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testResolvedSnapshotTerminalScrollbackSkipsFallbackWhenRestoreIsUnsafe() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: "fallback-value", + allowFallbackScrollback: false + ) + + XCTAssertNil(resolved) + } + private func makeSnapshot(version: Int) -> AppSessionSnapshot { let workspace = SessionWorkspaceSnapshot( processTitle: "Terminal", From da8c616dfe4dc22156e4b8444ab223130b99c8c6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 07:54:22 -0700 Subject: [PATCH 11/12] Fix cmux themes reload target resolution --- CLI/cmux.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index cc3b3faa..b88ad168 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -6014,6 +6014,11 @@ struct CMUXCLI { } private func currentCmuxAppBundleIdentifier() -> String? { + if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier From 7bd199b625c61a235755ac3b8b3e0b987cce26ec Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 15:48:49 -0700 Subject: [PATCH 12/12] Address cmux themes PR feedback --- CLI/cmux.swift | 39 +++++++++++++++++++++++++++---- Sources/AppDelegate.swift | 2 +- Sources/GhosttyTerminalView.swift | 22 +++++++---------- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b88ad168..91d79f29 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5961,7 +5961,7 @@ struct CMUXCLI { let directoryURL = configURL.deletingLastPathComponent() try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - let existingContents = (try? String(contentsOf: configURL, encoding: .utf8)) ?? "" + let existingContents = try readOptionalThemeOverrideContents(at: configURL) ?? "" let strippedContents = removingManagedThemeOverride(from: existingContents) .trimmingCharacters(in: .whitespacesAndNewlines) let block = """ @@ -5978,7 +5978,7 @@ struct CMUXCLI { private func clearManagedThemeOverride() throws -> URL { let fileManager = FileManager.default let configURL = try cmuxThemeOverrideConfigURL() - guard let existingContents = try? String(contentsOf: configURL, encoding: .utf8) else { + guard let existingContents = try readOptionalThemeOverrideContents(at: configURL) else { return configURL } @@ -5986,7 +5986,14 @@ struct CMUXCLI { .trimmingCharacters(in: .whitespacesAndNewlines) if strippedContents.isEmpty { - try? fileManager.removeItem(at: configURL) + do { + try fileManager.removeItem(at: configURL) + } catch { + guard !isThemeOverrideFileNotFoundError(error) else { + return configURL + } + throw error + } } else { try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8) } @@ -5994,6 +6001,28 @@ struct CMUXCLI { return configURL } + private func readOptionalThemeOverrideContents(at url: URL) throws -> String? { + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + guard isThemeOverrideFileNotFoundError(error) else { + throw error + } + return nil + } + } + + private func isThemeOverrideFileNotFoundError(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError + } + if nsError.domain == NSPOSIXErrorDomain { + return nsError.code == ENOENT + } + return false + } + private func removingManagedThemeOverride(from contents: String) -> String { let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"# guard let regex = try? NSRegularExpression(pattern: pattern) else { @@ -6007,8 +6036,8 @@ struct CMUXCLI { let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier DistributedNotificationCenter.default().post( name: Notification.Name(Self.cmuxThemesReloadNotificationName), - object: bundleIdentifier, - userInfo: nil + object: nil, + userInfo: ["bundleIdentifier": bundleIdentifier] ) return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9c4a55ba..133f44b7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2126,7 +2126,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self, selector: #selector(handleThemesReloadNotification(_:)), name: CmuxThemeNotifications.reloadConfig, - object: Bundle.main.bundleIdentifier, + object: nil, suspensionBehavior: .deliverImmediately ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ff8e3d32..95445b27 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1236,16 +1236,12 @@ class GhosttyApp { ) -> [URL] { guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] } - func configURLs(for bundleIdentifier: String) -> [URL] { + func existingConfigURLs(for bundleIdentifier: String) -> [URL] { let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true) return [ directory.appendingPathComponent("config", isDirectory: false), directory.appendingPathComponent("config.ghostty", isDirectory: false) - ] - } - - func hasConfig(_ urls: [URL]) -> Bool { - urls.contains { url in + ].filter { url in guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), let type = attrs[.type] as? FileAttributeType, type == .typeRegular, @@ -1256,13 +1252,13 @@ class GhosttyApp { } } - let currentURLs = configURLs(for: currentBundleIdentifier) - if hasConfig(currentURLs) { + let currentURLs = existingConfigURLs(for: currentBundleIdentifier) + if !currentURLs.isEmpty { return currentURLs } if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { - let releaseURLs = configURLs(for: releaseBundleIdentifier) - if hasConfig(releaseURLs) { + let releaseURLs = existingConfigURLs(for: releaseBundleIdentifier) + if !releaseURLs.isEmpty { return releaseURLs } } @@ -1325,11 +1321,11 @@ class GhosttyApp { } } - #if DEBUG - Self.initLog( +#if DEBUG + dlog( "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" ) - #endif +#endif #endif }