diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8346d1ab..91d79f29 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,35 @@ 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 + + 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 + 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 list + 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 +5367,722 @@ 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 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 + environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" + 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 + } + + try execInteractiveHelper( + executablePath: helperURL.path, + arguments: ["+list-themes"], + environment: environment + ) + } + + 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 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 } + + 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 + } + + 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 readOptionalThemeOverrideContents(at: configURL) ?? "" + 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 readOptionalThemeOverrideContents(at: configURL) else { + return configURL + } + + let strippedContents = removingManagedThemeOverride(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if strippedContents.isEmpty { + 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) + } + + 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 { + return contents + } + let fullRange = NSRange(contents.startIndex.. ThemeReloadStatus { + let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + DistributedNotificationCenter.default().post( + name: Notification.Name(Self.cmuxThemesReloadNotificationName), + object: nil, + userInfo: ["bundleIdentifier": bundleIdentifier] + ) + return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) + } + + 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 + } + + 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 +9151,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 7d72cb08..a0a4d27f 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 = { @@ -2129,6 +2133,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: nil, + 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. @@ -10879,6 +10891,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() { @@ -11470,4 +11490,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 8c33b597..85d68a89 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1208,9 +1208,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) } @@ -1411,20 +1411,40 @@ 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 existingConfigURLs(for bundleIdentifier: String) -> [URL] { + let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true) + return [ + directory.appendingPathComponent("config", isDirectory: false), + directory.appendingPathComponent("config.ghostty", isDirectory: false) + ].filter { 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 hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0 - return hasReleaseAppSupportConfig + let currentURLs = existingConfigURLs(for: currentBundleIdentifier) + if !currentURLs.isEmpty { + return currentURLs + } + if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { + let releaseURLs = existingConfigURLs(for: releaseBundleIdentifier) + if !releaseURLs.isEmpty { + return releaseURLs + } + } + return [] } static func shouldApplyDefaultBackgroundUpdate( @@ -1464,54 +1484,30 @@ 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 } - - 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 - ghostty_config_load_file(config, path) - } - } - - #if DEBUG - Self.initLog( - "loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)" + appSupportDirectory: appSupport, + fileManager: fm ) - #endif + guard !urls.isEmpty else { return } + + for url in urls { + url.path.withCString { path in + ghostty_config_load_file(config, path) + } + } + +#if DEBUG + dlog( + "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" + ) +#endif #endif } @@ -2516,7 +2512,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? var requestedWorkingDirectory: String? { workingDirectory } - private let additionalEnvironment: [String: String] + private var additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -3058,8 +3054,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 } } @@ -3118,6 +3115,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 9e02e2d5..7a8acf00 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -190,6 +190,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() { guard NotificationPaneFlashSettings.isEnabled() else { return } hostedView.triggerFlash() diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 619ac53c..2ac932ce 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1657,6 +1657,9 @@ class TerminalController { case "close_surface": return closeSurface(args) + case "reload_config": + return reloadConfig(args) + case "refresh_surfaces": return refreshSurfaces() @@ -9699,6 +9702,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 @@ -13925,6 +13929,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" } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ca6bdb53..02335c6b 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", diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index c57c12e6..49de9988 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -88,6 +88,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 follow-up 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 @@ -111,4 +125,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 404a3f17..bc9be90a 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 404a3f175ba6baafabc46cac807194883e040980 +Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 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