Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
b2346f9
test: cover repeated theme reload requests
austinywang May 19, 2026
1be9d26
fix: reload themes through cmux socket
austinywang May 19, 2026
d39f48d
Merge remote-tracking branch 'origin/main' into issue-4355-cmux-theme…
austinywang May 19, 2026
a270a92
fix: close theme reload socket once
austinywang May 19, 2026
43ce3c9
test: hard kill timed out cli children
austinywang May 19, 2026
935c7c9
fix: avoid terminal theme sync during settings init
austinywang May 19, 2026
d11e332
test: cover skipped ghostty theme helper fallback
austinywang May 19, 2026
47a1de4
fix: ignore skipped ghostty theme helper stub
austinywang May 19, 2026
e24a873
Revert "fix: ignore skipped ghostty theme helper stub"
austinywang May 19, 2026
b4cba6c
Revert "test: cover skipped ghostty theme helper fallback"
austinywang May 19, 2026
d3022ab
test: cover interactive theme picker reload
austinywang May 19, 2026
8a4e57c
fix: reload after interactive theme picker
austinywang May 19, 2026
378ebfc
Merge remote-tracking branch 'origin/main' into issue-4355-cmux-theme…
austinywang May 19, 2026
92c0737
test: cover interactive theme picker foregrounding
austinywang May 19, 2026
b0f58e4
fix: foreground interactive theme picker
austinywang May 19, 2026
415c1a0
fix: preserve theme picker launch diagnostics
austinywang May 19, 2026
7c38f92
test: cover inherited cmux socket context
austinywang May 19, 2026
b361e9a
fix: ignore inherited socket context from other cmux bundles
austinywang May 19, 2026
c5bab78
Merge remote-tracking branch 'origin/main' into issue-4355-cmux-theme…
austinywang May 19, 2026
6f00d74
fix: derive chrome contrast from terminal themes
austinywang May 19, 2026
e2b92eb
fix: treat theme picker interrupt as cancel
austinywang May 19, 2026
825ddfc
fix: return panel appearance config
austinywang May 19, 2026
098f38e
test: fix theme picker cancel fallback helper
austinywang May 19, 2026
6fedbb8
test: return window appearance fixture
austinywang May 19, 2026
4223df7
fix: keep sidebar chrome readable across themes
austinywang May 19, 2026
4b7d82f
Merge remote-tracking branch 'origin/main' into issue-4355-cmux-theme…
austinywang May 19, 2026
d354eca
fix: return selected row foreground color
austinywang May 19, 2026
b6d3470
fix: align terminal scheme with live theme
austinywang May 19, 2026
228f3ab
fix: synchronize theme contrast on reload
austinywang May 19, 2026
374958a
fix: expose surface color scheme reapply bridge
austinywang May 19, 2026
f7450b3
fix: derive terminal runtime scheme from theme background
austinywang May 19, 2026
4ac5023
fix: clear theme contrast ci feedback
austinywang May 19, 2026
5aa5d0a
fix: guard ghostty theme reload recursion
austinywang May 19, 2026
98b9015
merge: resolve conflicts with main
austinywang May 19, 2026
1ca833f
fix: reapply ghostty runtime scheme on theme reload
austinywang May 19, 2026
ebe8a89
fix: simplify ghostty reentrancy skip logging
austinywang May 19, 2026
f594d5a
fix: reuse readable color scheme for foreground
austinywang May 19, 2026
cbdbe76
fix: simplify runtime scheme sync decision API
austinywang May 19, 2026
15709e4
test: cover cmux theme reload notifications
austinywang May 19, 2026
59fc0ab
fix: keep cmux theme reloads stateful
austinywang May 19, 2026
cb30340
Merge remote-tracking branch 'origin/main' into issue-4355-cmux-theme…
austinywang May 20, 2026
7eb1676
fix: target theme reload notifications by socket
austinywang May 20, 2026
452745b
fix: align sidebar overlay contrast scheme
austinywang May 20, 2026
1ca5629
fix: preserve transparent terminal hosting
austinywang May 20, 2026
aac8005
fix: preserve right sidebar remembered mode
austinywang May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions CLI/CMUXCLI+ThemeSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -309,16 +309,74 @@ extension CMUXCLI {
return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "")
}

func reloadThemesIfPossible() -> ThemeReloadStatus {
let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
func reloadThemesIfPossible(
socketPath: String,
explicitPassword _: String?
) -> ThemeReloadStatus {
let bundleIdentifier = themeReloadTargetBundleIdentifier(socketPath: socketPath)
DistributedNotificationCenter.default().post(
name: Notification.Name(Self.cmuxThemesReloadNotificationName),
object: nil,
userInfo: ["bundleIdentifier": bundleIdentifier]
userInfo: [
"bundleIdentifier": bundleIdentifier,
"socketPath": socketPath,
"phase": "final",
]
)
return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier)
}
Comment thread
lawrencecchen marked this conversation as resolved.

func themeReloadTargetBundleIdentifier(socketPath: String) -> String {
bundleIdentifierForThemeReloadSocketPath(socketPath)
?? currentCmuxAppBundleIdentifier()
?? Self.cmuxThemeOverrideBundleIdentifier
}

private func bundleIdentifierForThemeReloadSocketPath(_ socketPath: String) -> String? {
let name = URL(fileURLWithPath: socketPath).lastPathComponent
switch name {
case "cmux.sock":
return Self.cmuxThemeOverrideBundleIdentifier
case "cmux-debug.sock":
return "com.cmuxterm.app.debug"
case "cmux-nightly.sock":
return "com.cmuxterm.app.nightly"
case "cmux-staging.sock":
return "com.cmuxterm.app.staging"
default:
break
}

if name.range(of: #"^cmux-\d+\.sock$"#, options: .regularExpression) != nil {
return Self.cmuxThemeOverrideBundleIdentifier
}

if let slug = themeReloadSocketSlug(name, prefix: "cmux-debug-", suffix: ".sock") {
return "com.cmuxterm.app.debug.\(slug)"
}
if let slug = themeReloadSocketSlug(name, prefix: "cmux-nightly-", suffix: ".sock") {
return "com.cmuxterm.app.nightly.\(slug)"
}
if let slug = themeReloadSocketSlug(name, prefix: "cmux-staging-", suffix: ".sock") {
return "com.cmuxterm.app.staging.\(slug)"
}
return nil
}

private func themeReloadSocketSlug(_ name: String, prefix: String, suffix: String) -> String? {
guard name.hasPrefix(prefix), name.hasSuffix(suffix) else {
return nil
}
let start = name.index(name.startIndex, offsetBy: prefix.count)
let end = name.index(name.endIndex, offsetBy: -suffix.count)
let rawSlug = String(name[start..<end])
let bundleSlug = rawSlug
.lowercased()
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: ".", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "."))
return bundleSlug.isEmpty ? nil : bundleSlug
}

func currentCmuxAppBundleIdentifier() -> String? {
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
Expand Down
138 changes: 110 additions & 28 deletions CLI/CMUXCLI+Themes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,28 @@ extension CMUXCLI {
case dark
}

private enum InteractiveHelperResult: Equatable {
case completed
case cancelled
}

private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool {
guard !jsonOutput else { return false }
return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
}

private func runInteractiveThemes() throws {
private func runInteractiveThemes(
socketPath: String,
explicitPassword: String?
) throws {
guard let helperURL = bundledHelperURL(named: "ghostty") else {
throw CLIError(message: "Bundled Ghostty theme picker helper not found")
}

let selection = currentThemeSelection()
var environment = ProcessInfo.processInfo.environment
environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path
environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
environment["CMUX_THEME_PICKER_BUNDLE_ID"] = themeReloadTargetBundleIdentifier(socketPath: socketPath)
environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue
environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light"
if let light = selection.light {
Expand All @@ -51,11 +59,13 @@ extension CMUXCLI {
environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path
}

try execInteractiveHelper(
let result = try runInteractiveHelper(
executablePath: helperURL.path,
arguments: ["+list-themes"],
environment: environment
)
guard result == .completed else { return }
_ = reloadThemesIfPossible(socketPath: socketPath, explicitPassword: explicitPassword)
}

private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode {
Expand Down Expand Up @@ -111,31 +121,75 @@ extension CMUXCLI {
return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) })
}

private func execInteractiveHelper(
private func runInteractiveHelper(
executablePath: String,
arguments: [String],
environment: [String: String]
) throws -> Never {
var argv = ([executablePath] + arguments).map { strdup($0) }
defer {
for item in argv {
free(item)
) throws -> InteractiveHelperResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.environment = environment
process.standardInput = FileHandle.standardInput
process.standardOutput = FileHandle.standardOutput
process.standardError = FileHandle.standardError

let originalForegroundProcessGroup = isatty(STDIN_FILENO) == 1 ? tcgetpgrp(STDIN_FILENO) : -1
var didForegroundChild = false
do {
try process.run()
} catch {
throw CLIError(message: "Failed to launch interactive theme picker: \(String(describing: error))")
}
if originalForegroundProcessGroup > 0 {
let childProcessGroup = getpgid(process.processIdentifier)
if childProcessGroup > 0 && childProcessGroup != originalForegroundProcessGroup {
do {
try setInteractiveThemePickerForegroundProcessGroup(childProcessGroup)
_ = Darwin.kill(-childProcessGroup, SIGCONT)
didForegroundChild = true
} catch {
process.terminate()
throw error
}
}
}
argv.append(nil)

var envp = environment
.map { key, value in strdup("\(key)=\(value)") }
defer {
for item in envp {
free(item)
if didForegroundChild {
try? setInteractiveThemePickerForegroundProcessGroup(originalForegroundProcessGroup)
}
}
envp.append(nil)

execve(executablePath, &argv, &envp)
let code = errno
throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))")
process.waitUntilExit()
if process.terminationReason == .exit, process.terminationStatus == 0 {
return .completed
}

if isInteractiveThemePickerCancellation(process) {
return .cancelled
} else if process.terminationReason == .uncaughtSignal {
throw CLIError(message: "Interactive theme picker exited from signal \(process.terminationStatus)")
}
throw CLIError(message: "Interactive theme picker exited with status \(process.terminationStatus)")
}

private func isInteractiveThemePickerCancellation(_ process: Process) -> Bool {
switch process.terminationReason {
case .uncaughtSignal:
return process.terminationStatus == SIGINT || process.terminationStatus == SIGTERM
case .exit:
return process.terminationStatus == 130 || process.terminationStatus == 143
@unknown default:
return false
}
}

private func setInteractiveThemePickerForegroundProcessGroup(_ processGroup: pid_t) throws {
let previousHandler = signal(SIGTTOU, SIG_IGN)
defer { _ = signal(SIGTTOU, previousHandler) }
guard tcsetpgrp(STDIN_FILENO, processGroup) == 0 else {
throw CLIError(message: "Interactive theme picker failed to enter foreground: \(String(cString: strerror(errno)))")
}
}

private func bundledGhosttyResourcesURL() -> URL? {
Expand Down Expand Up @@ -169,10 +223,15 @@ extension CMUXCLI {
return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true)
Comment thread
lawrencecchen marked this conversation as resolved.
}

func runThemes(commandArgs: [String], jsonOutput: Bool) throws {
func runThemes(
commandArgs: [String],
jsonOutput: Bool,
socketPath: String,
explicitPassword: String?
) throws {
if commandArgs.isEmpty {
if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) {
try runInteractiveThemes()
try runInteractiveThemes(socketPath: socketPath, explicitPassword: explicitPassword)
return
}
try printThemesList(jsonOutput: jsonOutput)
Expand All @@ -193,21 +252,29 @@ extension CMUXCLI {
case "set":
try runThemesSet(
args: Array(commandArgs.dropFirst()),
jsonOutput: jsonOutput
jsonOutput: jsonOutput,
socketPath: socketPath,
explicitPassword: explicitPassword
)
case "clear":
if commandArgs.count > 1 {
throw CLIError(message: "themes clear does not take any positional arguments")
}
try runThemesClear(jsonOutput: jsonOutput)
try runThemesClear(
jsonOutput: jsonOutput,
socketPath: socketPath,
explicitPassword: explicitPassword
)
default:
if subcommand.hasPrefix("-") {
throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.")
}

try runThemesSet(
args: commandArgs,
jsonOutput: jsonOutput
jsonOutput: jsonOutput,
socketPath: socketPath,
explicitPassword: explicitPassword
)
}
}
Expand Down Expand Up @@ -265,7 +332,12 @@ extension CMUXCLI {
}
}

private func runThemesSet(args: [String], jsonOutput: Bool) throws {
private func runThemesSet(
args: [String],
jsonOutput: Bool,
socketPath: String,
explicitPassword: String?
) throws {
let (lightOpt, rem0) = parseOption(args, name: "--light")
let (darkOpt, rem1) = parseOption(rem0, name: "--dark")

Expand Down Expand Up @@ -300,7 +372,10 @@ extension CMUXCLI {
}

let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue)
let reloadStatus = reloadThemesIfPossible()
let reloadStatus = reloadThemesIfPossible(
socketPath: socketPath,
explicitPassword: explicitPassword
)

if jsonOutput {
let payload: [String: Any] = [
Expand All @@ -321,9 +396,16 @@ extension CMUXCLI {
)
}

private func runThemesClear(jsonOutput: Bool) throws {
private func runThemesClear(
jsonOutput: Bool,
socketPath: String,
explicitPassword: String?
) throws {
let configURL = try clearManagedThemeOverride()
let reloadStatus = reloadThemesIfPossible()
let reloadStatus = reloadThemesIfPossible(
socketPath: socketPath,
explicitPassword: explicitPassword
)

if jsonOutput {
let payload: [String: Any] = [
Expand Down
4 changes: 3 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2458,7 +2458,9 @@ struct CMUXCLI {
if command == "themes" {
try runThemes(
commandArgs: commandArgs,
jsonOutput: jsonOutput
jsonOutput: jsonOutput,
socketPath: resolvedSocketPath,
explicitPassword: socketPasswordArg
)
return
}
Expand Down
35 changes: 35 additions & 0 deletions Sources/App/GhosttySurfaceConfigurationRefresh.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,50 @@
@MainActor
enum GhosttySurfaceConfigurationRefresh {
nonisolated static let forceRefreshReason = "appDelegate.refreshAfterGhosttyConfigReload"
nonisolated static let cmuxThemeReloadLegacySource = "distributed.cmux.themes"
nonisolated static let cmuxThemeReloadPreviewSource = "distributed.cmux.themes.preview"
nonisolated static let cmuxThemeReloadFinalSource = "distributed.cmux.themes.final"
nonisolated static let cmuxThemePreviewReloadDebounceMilliseconds = 180

nonisolated static func cmuxThemeReloadSource(phase: String?) -> String {
switch phase {
case "final", "apply":
return cmuxThemeReloadFinalSource
case "preview":
return cmuxThemeReloadPreviewSource
default:
return cmuxThemeReloadLegacySource
}
}

nonisolated static func shouldDebounceCmuxThemeReload(source: String) -> Bool {
switch source {
case cmuxThemeReloadLegacySource, cmuxThemeReloadPreviewSource:
return true
default:
return false
}
}

nonisolated static func isCmuxThemeReloadSource(_ source: String) -> Bool {
switch source {
case cmuxThemeReloadLegacySource, cmuxThemeReloadPreviewSource, cmuxThemeReloadFinalSource:
return true
default:
return false
}
}

static func applyAfterAppConfigReload(
to surface: ghostty_surface_t?,
source: String,
reloadSurfaceConfiguration: (ghostty_surface_t, Bool, String) -> Void,
applySurfaceColorScheme: () -> Void,
refreshHostBackground: () -> Void,
forceRefresh: (String) -> Void
) {
if let surface {
applySurfaceColorScheme()
reloadSurfaceConfiguration(surface, true, source)
}
refreshHostBackground()
Expand Down
Loading
Loading