Merge pull request #1334 from manaflow-ai/feat-cmux-themes-command

Add cmux themes command
This commit is contained in:
Lawrence Chen 2026-03-13 17:23:28 -07:00 committed by GitHub
commit 255ec0016c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 960 additions and 64 deletions

View file

@ -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 <theme>
cmux themes set --light <theme> [--dark <theme>]
cmux themes set --dark <theme> [--light <theme>]
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 <theme> Set the same theme for both light and dark appearance
set --light <theme> Set the light appearance theme
set --dark <theme> 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 <theme>, --dark <theme>")
}
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<String> = []
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<String> = []
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..<contents.endIndex, in: contents)
return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "")
}
private func reloadThemesIfPossible() -> 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 <email> --body <text> [--image <path> ...]]
themes [list|set|clear]
claude-teams [claude-args...]
ping
capabilities

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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()

View file

@ -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 <pane-id|index> - Focus a pane
focus_surface_by_panel <panel_id> - 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" }

View file

@ -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

View file

@ -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",

View file

@ -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.

@ -1 +1 @@
Subproject commit 404a3f175ba6baafabc46cac807194883e040980
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42

View file

@ -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