Use Ghostty theme picker for cmux themes

This commit is contained in:
Lawrence Chen 2026-03-13 04:24:19 -07:00
parent 6584a01aef
commit ae24dad89f
No known key found for this signature in database
4 changed files with 173 additions and 3 deletions

View file

@ -4281,8 +4281,11 @@ struct CMUXCLI {
cmux themes set --dark <theme> [--light <theme>]
cmux themes clear
List bundled and discovered Ghostty themes, show the current cmux light/dark
theme defaults, and update the default theme override used by cmux.
When run in a TTY, `cmux themes` opens an interactive theme picker with
live app preview. Use `cmux themes list` for a plain listing.
The picker previews the selected theme across the running cmux app and
lets you apply it to the light theme, dark theme, or both defaults.
Commands:
list List available themes and mark the current light/dark defaults
@ -4293,6 +4296,7 @@ struct CMUXCLI {
Examples:
cmux themes
cmux themes list
cmux themes set "Catppuccin Mocha"
cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha"
cmux themes clear
@ -5380,8 +5384,145 @@ struct CMUXCLI {
let targetBundleIdentifier: String
}
private enum ThemePickerTargetMode: String {
case both
case light
case dark
}
private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool {
guard !jsonOutput else { return false }
return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1
}
private func runInteractiveThemes() throws {
guard let helperURL = bundledHelperURL(named: "ghostty") else {
throw CLIError(message: "Bundled Ghostty theme picker helper not found")
}
let selection = currentThemeSelection()
var environment = ProcessInfo.processInfo.environment
environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path
environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier
environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue
if let light = selection.light {
environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light
}
if let dark = selection.dark {
environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark
}
if let resourcesURL = bundledGhosttyResourcesURL() {
environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path
}
let process = Process()
process.executableURL = helperURL
process.arguments = ["+list-themes"]
process.environment = environment
process.standardInput = FileHandle.standardInput
process.standardOutput = FileHandle.standardOutput
process.standardError = FileHandle.standardError
try process.run()
process.waitUntilExit()
guard process.terminationReason == .exit else {
throw CLIError(message: "Interactive theme picker terminated unexpectedly")
}
guard process.terminationStatus == 0 else {
throw CLIError(message: "Interactive theme picker failed with status \(process.terminationStatus)")
}
}
private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode {
if let light = current.light,
let dark = current.dark,
light.caseInsensitiveCompare(dark) == .orderedSame {
return .both
}
return defaultAppearancePrefersDarkThemes() ? .dark : .light
}
private func defaultAppearancePrefersDarkThemes() -> Bool {
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame
}
private func bundledHelperURL(named helperName: String) -> URL? {
let fileManager = FileManager.default
guard let executableURL = resolvedExecutableURL() else { return nil }
var candidates: [URL] = [
executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false)
]
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.lastPathComponent == "Contents" {
candidates.append(
current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
.appendingPathComponent(helperName, isDirectory: false)
)
}
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
let repoHelper = current
.appendingPathComponent("ghostty", isDirectory: true)
.appendingPathComponent("zig-out", isDirectory: true)
.appendingPathComponent("bin", isDirectory: true)
.appendingPathComponent(helperName, isDirectory: false)
if fileManager.fileExists(atPath: projectMarker.path),
fileManager.isExecutableFile(atPath: repoHelper.path) {
candidates.append(repoHelper)
break
}
guard let parent = parentSearchURL(for: current) else { break }
current = parent
}
return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) })
}
private func bundledGhosttyResourcesURL() -> URL? {
let fileManager = FileManager.default
guard let executableURL = resolvedExecutableURL() else { return nil }
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.lastPathComponent == "Contents" {
let candidate = current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("ghostty", isDirectory: true)
if fileManager.fileExists(atPath: candidate.path) {
return candidate
}
}
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false)
let repoResources = current
.appendingPathComponent("Resources", isDirectory: true)
.appendingPathComponent("ghostty", isDirectory: true)
if fileManager.fileExists(atPath: projectMarker.path),
fileManager.fileExists(atPath: repoResources.path) {
return repoResources
}
guard let parent = parentSearchURL(for: current) else { break }
current = parent
}
return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true)
}
private func runThemes(commandArgs: [String], jsonOutput: Bool) throws {
if commandArgs.isEmpty {
if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) {
try runInteractiveThemes()
return
}
try printThemesList(jsonOutput: jsonOutput)
return
}

View file

@ -86,6 +86,20 @@ touch the same stale-frame mitigation path and tend to conflict in the same file
The fork branch HEAD is now the section 6 zsh redraw commit.
### 7) cmux theme picker helper hooks
- Commit: `0c52c987b` (Add cmux theme picker helper hooks)
- Files:
- `build.zig`
- `src/cli/list_themes.zig`
- `src/main_ghostty.zig`
- Summary:
- Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS.
- Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview.
- Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain.
The fork branch HEAD is now the section 7 cmux theme picker helper commit.
## Upstreamed fork changes
### cursor-click-to-move respects OSC 133 click-to-move
@ -109,4 +123,9 @@ These files change frequently upstream; be careful when rebasing the fork:
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}`
prompt newlines should not get an extra explicit continuation marker after the hidden CR.
- `src/cli/list_themes.zig`
- cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore.
If upstream reorganizes the preview loop or key handling, re-check the cmux mode path and keep the
stock Ghostty behavior unchanged when the cmux env vars are absent.
If you resolve a conflict, update this doc with what changed.

@ -1 +1 @@
Subproject commit 312c7b23a7c8dc0704431940d76ba5dc32a46afb
Subproject commit 0c52c987be769f244c740b305033a2ec9d85b982

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