diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 75f64401..0326b887 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4281,8 +4281,11 @@ struct CMUXCLI { cmux themes set --dark [--light ] cmux themes clear - List bundled and discovered Ghostty themes, show the current cmux light/dark - theme defaults, and update the default theme override used by cmux. + When run in a TTY, `cmux themes` opens an interactive theme picker with + live app preview. Use `cmux themes list` for a plain listing. + + The picker previews the selected theme across the running cmux app and + lets you apply it to the light theme, dark theme, or both defaults. Commands: list List available themes and mark the current light/dark defaults @@ -4293,6 +4296,7 @@ struct CMUXCLI { Examples: cmux themes + cmux themes list cmux themes set "Catppuccin Mocha" cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" cmux themes clear @@ -5380,8 +5384,145 @@ struct CMUXCLI { let targetBundleIdentifier: String } + private enum ThemePickerTargetMode: String { + case both + case light + case dark + } + + private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool { + guard !jsonOutput else { return false } + return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 + } + + private func runInteractiveThemes() throws { + guard let helperURL = bundledHelperURL(named: "ghostty") else { + throw CLIError(message: "Bundled Ghostty theme picker helper not found") + } + + let selection = currentThemeSelection() + var environment = ProcessInfo.processInfo.environment + environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path + environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue + if let light = selection.light { + environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light + } + if let dark = selection.dark { + environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark + } + if let resourcesURL = bundledGhosttyResourcesURL() { + environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path + } + + let process = Process() + process.executableURL = helperURL + process.arguments = ["+list-themes"] + process.environment = environment + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + try process.run() + process.waitUntilExit() + + guard process.terminationReason == .exit else { + throw CLIError(message: "Interactive theme picker terminated unexpectedly") + } + guard process.terminationStatus == 0 else { + throw CLIError(message: "Interactive theme picker failed with status \(process.terminationStatus)") + } + } + + private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { + if let light = current.light, + let dark = current.dark, + light.caseInsensitiveCompare(dark) == .orderedSame { + return .both + } + return defaultAppearancePrefersDarkThemes() ? .dark : .light + } + + private func defaultAppearancePrefersDarkThemes() -> Bool { + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) + let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame + } + + private func bundledHelperURL(named helperName: String) -> URL? { + let fileManager = FileManager.default + guard let executableURL = resolvedExecutableURL() else { return nil } + + var candidates: [URL] = [ + executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false) + ] + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Contents" { + candidates.append( + current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent(helperName, isDirectory: false) + ) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + let repoHelper = current + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("zig-out", isDirectory: true) + .appendingPathComponent("bin", isDirectory: true) + .appendingPathComponent(helperName, isDirectory: false) + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.isExecutableFile(atPath: repoHelper.path) { + candidates.append(repoHelper) + break + } + + guard let parent = parentSearchURL(for: current) else { break } + current = parent + } + + return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) + } + + private func bundledGhosttyResourcesURL() -> URL? { + let fileManager = FileManager.default + guard let executableURL = resolvedExecutableURL() else { return nil } + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Contents" { + let candidate = current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + if fileManager.fileExists(atPath: candidate.path) { + return candidate + } + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + let repoResources = current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoResources.path) { + return repoResources + } + + guard let parent = parentSearchURL(for: current) else { break } + current = parent + } + + return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true) + } + private func runThemes(commandArgs: [String], jsonOutput: Bool) throws { if commandArgs.isEmpty { + if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { + try runInteractiveThemes() + return + } try printThemesList(jsonOutput: jsonOutput) return } diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index d85ca46b..0797f4be 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -86,6 +86,20 @@ touch the same stale-frame mitigation path and tend to conflict in the same file The fork branch HEAD is now the section 6 zsh redraw commit. +### 7) cmux theme picker helper hooks + +- Commit: `0c52c987b` (Add cmux theme picker helper hooks) +- Files: + - `build.zig` + - `src/cli/list_themes.zig` + - `src/main_ghostty.zig` +- Summary: + - Adds a `zig build cli-helper` step so cmux can bundle Ghostty's CLI helper binary on macOS. + - Lets `+list-themes` switch into a cmux-managed mode via env vars, writing the cmux theme override file and posting the existing cmux reload notification for live app-wide preview. + - Fixes the helper-only `app-runtime=none` stdout path so the Ghostty CLI binary builds with the current Zig toolchain. + +The fork branch HEAD is now the section 7 cmux theme picker helper commit. + ## Upstreamed fork changes ### cursor-click-to-move respects OSC 133 click-to-move @@ -109,4 +123,9 @@ These files change frequently upstream; be careful when rebasing the fork: `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}` prompt newlines should not get an extra explicit continuation marker after the hidden CR. +- `src/cli/list_themes.zig` + - cmux now relies on the upstream picker UI plus local env-driven hooks for live preview and restore. + If upstream reorganizes the preview loop or key handling, re-check the cmux mode path and keep the + stock Ghostty behavior unchanged when the cmux env vars are absent. + If you resolve a conflict, update this doc with what changed. diff --git a/ghostty b/ghostty index 312c7b23..0c52c987 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 312c7b23a7c8dc0704431940d76ba5dc32a46afb +Subproject commit 0c52c987be769f244c740b305033a2ec9d85b982 diff --git a/scripts/reload.sh b/scripts/reload.sh index 4e758a88..9c8df452 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -306,15 +306,25 @@ else fi sleep 0.3 CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd" +GHOSTTY_HELPER_SRC="$PWD/ghostty/zig-out/bin/ghostty" if [[ -d "$PWD/cmuxd" ]]; then (cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast) fi +if [[ -d "$PWD/ghostty" ]]; then + (cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast) +fi if [[ -x "$CMUXD_SRC" ]]; then BIN_DIR="$APP_PATH/Contents/Resources/bin" mkdir -p "$BIN_DIR" cp "$CMUXD_SRC" "$BIN_DIR/cmuxd" chmod +x "$BIN_DIR/cmuxd" fi +if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then + BIN_DIR="$APP_PATH/Contents/Resources/bin" + mkdir -p "$BIN_DIR" + cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty" + chmod +x "$BIN_DIR/ghostty" +fi CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [[ -x "$CLI_PATH" ]]; then echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true