native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #237 from onevcat/feat/ghostty-single-theme-fallback

fix: runtime fallback for single Ghostty theme mismatch

authored by

Wei Wang and committed by
GitHub
d837c3e2 930ce58c

+442 -15
+20 -4
supacode/Features/Settings/Views/AppearanceSettingsView.swift
··· 20 20 } 21 21 } 22 22 } 23 - VStack(alignment: .leading, spacing: 4) { 24 - Text("Terminal theming follows Ghostty config") 25 - Text("For example, add the following line to `~/.config/ghostty/config`") 23 + VStack(alignment: .leading, spacing: 6) { 24 + Text( 25 + """ 26 + Terminal theming follows your Ghostty configuration. \ 27 + Browse [all built-in themes](https://iterm2colorschemes.com/), \ 28 + then add a dual-theme line such as: 29 + """ 30 + ) 26 31 Text("theme = light:Monokai Pro Light Sun,dark:Dimmed Monokai") 27 32 .monospaced() 33 + .textSelection(.enabled) 34 + HStack(spacing: 8) { 35 + Button("Open Config") { 36 + GhosttyRuntime.openGhosttyConfig() 37 + } 38 + .help("Open your Ghostty config file in the default text editor.") 39 + Button("Reload") { 40 + GhosttyRuntime.shared?.reloadAppConfig() 41 + } 42 + .help("Re-read the Ghostty config from disk and apply it to running terminals.") 43 + } 44 + .controlSize(.small) 28 45 } 29 46 .font(.footnote) 30 47 .foregroundStyle(.secondary) 31 - .textSelection(.enabled) 32 48 } 33 49 Section("Default View") { 34 50 Picker("Launch in", selection: $store.defaultViewMode) {
+372 -11
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 3 3 import SwiftUI 4 4 import UniformTypeIdentifiers 5 5 6 - private let ghosttyLogger = SupaLogger("GhosttyRuntime") 6 + nonisolated private let ghosttyLogger = SupaLogger("GhosttyRuntime") 7 7 8 8 final class GhosttyRuntime { 9 + nonisolated private static let ghosttyExecutableCandidates = [ 10 + "/Applications/Ghostty.app/Contents/MacOS/ghostty", 11 + "/opt/homebrew/bin/ghostty", 12 + "/usr/local/bin/ghostty", 13 + ] 14 + nonisolated private static let ghosttyCLICacheLock = NSLock() 15 + nonisolated(unsafe) private static var cachedGhosttyExecutablePath: String? 16 + nonisolated(unsafe) private static var ghosttyExecutableResolutionAttempted = false 17 + nonisolated(unsafe) private static var cachedFallbackThemePair: GhosttyThemePair? 18 + // Prowl constructs a single GhosttyRuntime for the whole app lifetime 19 + // (see `supacodeApp.init`). This weak reference gives UI surfaces that 20 + // don't otherwise have access to the runtime (e.g. the Settings window) a 21 + // direct way to trigger app-level actions without threading the instance 22 + // through SwiftUI's environment or reducers. 23 + static weak var shared: GhosttyRuntime? 24 + 9 25 final class SurfaceReference { 10 26 let surface: ghostty_surface_t 11 27 var isValid = true ··· 24 40 private var observers: [NSObjectProtocol] = [] 25 41 private var surfaceRefs: [SurfaceReference] = [] 26 42 private var lastColorScheme: ghostty_color_scheme_e? 43 + private var currentColorScheme: ColorScheme? 27 44 private var appKeybindOverrideContents = "" 28 45 private var appKeybindOverrideEntries: [String] = [] 46 + private var themeFallbackOverrideContents = "" 47 + private var runtimeOverrideSignature = "" 29 48 var onConfigChange: (() -> Void)? 30 49 var onQuit: (() -> Void)? 31 50 ··· 63 82 } 64 83 self.app = app 65 84 85 + Self.shared = self 66 86 registerNotificationObservers() 67 87 } 68 88 ··· 182 202 183 203 func setColorScheme(_ scheme: ColorScheme) { 184 204 guard let app else { return } 205 + currentColorScheme = scheme 185 206 let ghosttyScheme: ghostty_color_scheme_e = 186 207 scheme == .dark 187 208 ? GHOSTTY_COLOR_SCHEME_DARK ··· 189 210 lastColorScheme = ghosttyScheme 190 211 ghostty_app_set_color_scheme(app, ghosttyScheme) 191 212 applyColorSchemeToSurfaces(ghosttyScheme) 213 + reconcileThemeFallback(for: scheme) 192 214 } 193 215 194 216 func registerSurface(_ surface: ghostty_surface_t) -> SurfaceReference { ··· 219 241 ghostty_config_free(config) 220 242 } 221 243 244 + /// Re-reads the user's Ghostty config from disk and re-applies Prowl's 245 + /// runtime overrides on top of it. Intended for the Settings UI so users 246 + /// can pick up edits without restarting the app. 247 + func reloadAppConfig() { 248 + // Force `applyRuntimeOverridesIfNeeded` to rebuild and push a fresh 249 + // config to ghostty, regardless of whether our override contents changed. 250 + runtimeOverrideSignature = "" 251 + applyRuntimeOverridesIfNeeded() 252 + } 253 + 222 254 private func applyConfig( 223 255 _ config: ghostty_config_t, 224 256 target: ghostty_target_s, ··· 423 455 let config = action.action.config_change.config 424 456 guard let clone = ghostty_config_clone(config) else { return false } 425 457 runtime.setConfig(clone) 458 + if let scheme = runtime.currentColorScheme { 459 + runtime.reconcileThemeFallback(for: scheme) 460 + } 426 461 runtime.onConfigChange?() 427 462 NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: runtime) 428 463 } ··· 462 497 } 463 498 } 464 499 465 - private static func openGhosttyConfig() { 500 + static func openGhosttyConfig() { 466 501 let configStr = ghostty_config_open_path() 467 502 defer { ghostty_string_free(configStr) } 468 503 guard let ptr = configStr.ptr else { return } ··· 552 587 guard contents != appKeybindOverrideContents else { return } 553 588 appKeybindOverrideEntries = entries 554 589 appKeybindOverrideContents = contents 590 + applyRuntimeOverridesIfNeeded() 591 + } 555 592 556 - let overrideURL = URL(fileURLWithPath: NSTemporaryDirectory()) 557 - .appendingPathComponent("prowl-ghostty-keybind-overrides.conf") 558 - do { 559 - try contents.write(to: overrideURL, atomically: true, encoding: .utf8) 560 - } catch { 561 - ghosttyLogger.warning("Failed to write ghostty keybind override file: \(error.localizedDescription)") 593 + private func reconcileThemeFallback(for scheme: ColorScheme) { 594 + // Subprocess discovery of the user's Ghostty CLI can block the main 595 + // thread (and in XCTest host bringup it has timed out the test runner 596 + // preparation phase). Short-circuit under test, and dispatch the 597 + // lookup off-main in all other cases. 598 + guard !Self.isRunningInTestEnvironment() else { 599 + setThemeFallbackOverride("") 600 + return 601 + } 602 + Task { [weak self] in 603 + let snapshot = await Self.probeUserConfigSnapshot() 604 + let pair: GhosttyThemePair? = 605 + snapshot?.themeMode == .single ? await Self.probeFallbackThemePair() : nil 606 + self?.applyResolvedThemeFallback(for: scheme, snapshot: snapshot, pair: pair) 607 + } 608 + } 609 + 610 + @MainActor 611 + private func applyResolvedThemeFallback( 612 + for scheme: ColorScheme, 613 + snapshot: GhosttyUserConfigSnapshot?, 614 + pair: GhosttyThemePair? 615 + ) { 616 + guard currentColorScheme == scheme else { return } 617 + guard let snapshot, snapshot.themeMode == .single else { 618 + setThemeFallbackOverride("") 562 619 return 563 620 } 564 621 622 + let targetTone: GhosttyTerminalTone = scheme == .dark ? .dark : .light 623 + guard snapshot.backgroundTone == .light || snapshot.backgroundTone == .dark else { 624 + setThemeFallbackOverride("") 625 + return 626 + } 627 + 628 + if snapshot.backgroundTone == targetTone { 629 + setThemeFallbackOverride("") 630 + return 631 + } 632 + 633 + guard let pair else { 634 + setThemeFallbackOverride("") 635 + return 636 + } 637 + 638 + setThemeFallbackOverride("theme = light:\(pair.light),dark:\(pair.dark)") 639 + } 640 + 641 + nonisolated private static func isRunningInTestEnvironment() -> Bool { 642 + let env = ProcessInfo.processInfo.environment 643 + return env["XCTestConfigurationFilePath"] != nil 644 + || env["XCTestBundlePath"] != nil 645 + || env["XCTestSessionIdentifier"] != nil 646 + } 647 + 648 + private func setThemeFallbackOverride(_ contents: String) { 649 + guard contents != themeFallbackOverrideContents else { return } 650 + themeFallbackOverrideContents = contents 651 + applyRuntimeOverridesIfNeeded() 652 + } 653 + 654 + private func applyRuntimeOverridesIfNeeded() { 565 655 guard let app else { return } 656 + 657 + let nextSignature = [appKeybindOverrideContents, themeFallbackOverrideContents].joined(separator: "\n---\n") 658 + guard nextSignature != runtimeOverrideSignature else { return } 659 + 660 + var overrideURLs: [URL] = [] 661 + if !appKeybindOverrideContents.isEmpty { 662 + let url = URL(fileURLWithPath: NSTemporaryDirectory()) 663 + .appendingPathComponent("prowl-ghostty-keybind-overrides.conf") 664 + do { 665 + try appKeybindOverrideContents.write(to: url, atomically: true, encoding: .utf8) 666 + overrideURLs.append(url) 667 + } catch { 668 + ghosttyLogger.warning("Failed to write ghostty keybind override file: \(error.localizedDescription)") 669 + return 670 + } 671 + } 672 + 673 + if !themeFallbackOverrideContents.isEmpty { 674 + let url = URL(fileURLWithPath: NSTemporaryDirectory()) 675 + .appendingPathComponent("prowl-ghostty-theme-overrides.conf") 676 + do { 677 + try themeFallbackOverrideContents.write(to: url, atomically: true, encoding: .utf8) 678 + overrideURLs.append(url) 679 + } catch { 680 + ghosttyLogger.warning("Failed to write ghostty theme override file: \(error.localizedDescription)") 681 + return 682 + } 683 + } 684 + 566 685 guard let updated = ghostty_config_new() else { return } 567 686 ghostty_config_load_default_files(updated) 568 687 ghostty_config_load_recursive_files(updated) 569 688 ghostty_config_load_cli_args(updated) 570 - overrideURL.path.withCString { path in 571 - ghostty_config_load_file(updated, path) 689 + for url in overrideURLs { 690 + url.path.withCString { path in 691 + ghostty_config_load_file(updated, path) 692 + } 572 693 } 573 694 ghostty_config_finalize(updated) 574 695 ghostty_app_update_config(app, updated) ··· 576 697 setConfig(clone) 577 698 } 578 699 ghostty_config_free(updated) 700 + runtimeOverrideSignature = nextSignature 579 701 onConfigChange?() 580 702 NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: self) 581 703 } 582 704 705 + nonisolated private static func probeUserConfigSnapshot() async -> GhosttyUserConfigSnapshot? { 706 + // `await` ensures this runs on a cooperative executor rather than on the 707 + // caller's MainActor, so the synchronous subprocess calls below never block 708 + // the main thread. 709 + await Task.yield() 710 + return userConfigSnapshotFromCLI() 711 + } 712 + 713 + nonisolated private static func probeFallbackThemePair() async -> GhosttyThemePair? { 714 + await Task.yield() 715 + return resolveFallbackThemePair() 716 + } 717 + 718 + nonisolated private static func userConfigSnapshotFromCLI() -> GhosttyUserConfigSnapshot? { 719 + guard let output = runGhosttyCommand(arguments: ["+show-config"]) else { return nil } 720 + return GhosttyUserConfigSnapshot.parse(showConfigOutput: output) 721 + } 722 + 723 + nonisolated private static func runGhosttyCommand(arguments: [String]) -> String? { 724 + guard let executablePath = resolveGhosttyExecutablePath() else { return nil } 725 + let process = Process() 726 + process.executableURL = URL(fileURLWithPath: executablePath) 727 + process.arguments = arguments 728 + let outputPipe = Pipe() 729 + process.standardOutput = outputPipe 730 + process.standardError = Pipe() 731 + do { 732 + try process.run() 733 + process.waitUntilExit() 734 + } catch { 735 + let command = arguments.joined(separator: " ") 736 + ghosttyLogger.warning( 737 + "Failed to run ghostty command \(command): \(error.localizedDescription)" 738 + ) 739 + return nil 740 + } 741 + guard process.terminationStatus == 0 else { return nil } 742 + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() 743 + guard !data.isEmpty else { return "" } 744 + return String(data: data, encoding: .utf8) 745 + } 746 + 747 + nonisolated private static func resolveGhosttyExecutablePath() -> String? { 748 + ghosttyCLICacheLock.lock() 749 + if let cachedGhosttyExecutablePath, 750 + FileManager.default.isExecutableFile(atPath: cachedGhosttyExecutablePath) 751 + { 752 + defer { ghosttyCLICacheLock.unlock() } 753 + return cachedGhosttyExecutablePath 754 + } 755 + if ghosttyExecutableResolutionAttempted { 756 + ghosttyCLICacheLock.unlock() 757 + return nil 758 + } 759 + ghosttyCLICacheLock.unlock() 760 + 761 + var resolvedPath: String? 762 + for candidate in ghosttyExecutableCandidates where FileManager.default.isExecutableFile(atPath: candidate) { 763 + resolvedPath = candidate 764 + break 765 + } 766 + 767 + if resolvedPath == nil { 768 + let which = Process() 769 + which.executableURL = URL(fileURLWithPath: "/usr/bin/which") 770 + which.arguments = ["ghostty"] 771 + let outputPipe = Pipe() 772 + which.standardOutput = outputPipe 773 + which.standardError = Pipe() 774 + do { 775 + try which.run() 776 + which.waitUntilExit() 777 + } catch { 778 + ghosttyCLICacheLock.lock() 779 + ghosttyExecutableResolutionAttempted = true 780 + ghosttyCLICacheLock.unlock() 781 + return nil 782 + } 783 + 784 + if which.terminationStatus == 0 { 785 + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() 786 + if let path = String(data: data, encoding: .utf8)? 787 + .trimmingCharacters(in: .whitespacesAndNewlines), 788 + !path.isEmpty, 789 + FileManager.default.isExecutableFile(atPath: path) 790 + { 791 + resolvedPath = path 792 + } 793 + } 794 + } 795 + 796 + ghosttyCLICacheLock.lock() 797 + defer { ghosttyCLICacheLock.unlock() } 798 + ghosttyExecutableResolutionAttempted = true 799 + cachedGhosttyExecutablePath = resolvedPath 800 + return resolvedPath 801 + } 802 + 803 + nonisolated private static func resolveFallbackThemePair() -> GhosttyThemePair? { 804 + ghosttyCLICacheLock.lock() 805 + if let cachedFallbackThemePair { 806 + defer { ghosttyCLICacheLock.unlock() } 807 + return cachedFallbackThemePair 808 + } 809 + ghosttyCLICacheLock.unlock() 810 + 811 + let knownLightCandidates = ["Ghostty Default Style Light", "Catppuccin Latte"] 812 + let knownDarkCandidates = ["Ghostty Default Style Dark", "Catppuccin Frappe"] 813 + 814 + var resolvedPair: GhosttyThemePair? 815 + if let output = runGhosttyCommand(arguments: ["+list-themes"]) { 816 + let availableThemes = Set( 817 + output 818 + .split(whereSeparator: \.isNewline) 819 + .map { line -> String in 820 + let raw = String(line).trimmingCharacters(in: .whitespacesAndNewlines) 821 + if let index = raw.lastIndex(of: "("), raw.hasSuffix(")") { 822 + return String(raw[..<index]).trimmingCharacters(in: .whitespacesAndNewlines) 823 + } 824 + return raw 825 + } 826 + .filter { !$0.isEmpty } 827 + ) 828 + 829 + if let light = knownLightCandidates.first(where: { availableThemes.contains($0) }), 830 + let dark = knownDarkCandidates.first(where: { availableThemes.contains($0) }) 831 + { 832 + resolvedPair = GhosttyThemePair(light: light, dark: dark) 833 + } 834 + } 835 + 836 + let pair = resolvedPair 837 + ?? GhosttyThemePair(light: "Catppuccin Latte", dark: "Ghostty Default Style Dark") 838 + ghosttyCLICacheLock.lock() 839 + cachedFallbackThemePair = pair 840 + ghosttyCLICacheLock.unlock() 841 + return pair 842 + } 843 + 583 844 private static func keybindEntries(from keybindArguments: [String]) -> [String] { 584 845 let prefix = "--keybind=" 585 846 return keybindArguments.compactMap { argument in ··· 744 1005 ] 745 1006 } 746 1007 1008 + nonisolated struct GhosttyThemePair: Equatable, Sendable { 1009 + let light: String 1010 + let dark: String 1011 + } 1012 + 1013 + nonisolated enum GhosttyThemeMode: Equatable, Sendable { 1014 + case none 1015 + case single 1016 + case dual 1017 + } 1018 + 1019 + nonisolated enum GhosttyTerminalTone: Equatable, Sendable { 1020 + case light 1021 + case dark 1022 + case unknown 1023 + } 1024 + 1025 + nonisolated struct GhosttyUserConfigSnapshot: Equatable, Sendable { 1026 + let themeMode: GhosttyThemeMode 1027 + let backgroundTone: GhosttyTerminalTone 1028 + 1029 + static func parse(showConfigOutput: String) -> GhosttyUserConfigSnapshot { 1030 + var themeSpec: String? 1031 + var backgroundSpec: String? 1032 + 1033 + for rawLine in showConfigOutput.split(whereSeparator: \.isNewline) { 1034 + let line = String(rawLine) 1035 + guard let separator = line.firstIndex(of: "=") else { continue } 1036 + let key = line[..<separator].trimmingCharacters(in: .whitespacesAndNewlines) 1037 + let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines) 1038 + switch key { 1039 + case "theme": 1040 + themeSpec = value 1041 + case "background": 1042 + backgroundSpec = value 1043 + default: 1044 + continue 1045 + } 1046 + } 1047 + 1048 + let themeMode = parseThemeMode(from: themeSpec) 1049 + let backgroundTone = classifyBackgroundTone(from: backgroundSpec) 1050 + return .init(themeMode: themeMode, backgroundTone: backgroundTone) 1051 + } 1052 + 1053 + private static func parseThemeMode(from spec: String?) -> GhosttyThemeMode { 1054 + guard let spec, !spec.isEmpty else { return .none } 1055 + 1056 + var hasLight = false 1057 + var hasDark = false 1058 + 1059 + for rawPart in spec.split(separator: ",", omittingEmptySubsequences: true) { 1060 + let part = rawPart.trimmingCharacters(in: .whitespacesAndNewlines) 1061 + guard let separator = part.firstIndex(of: ":") else { continue } 1062 + let key = part[..<separator].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 1063 + switch key { 1064 + case "light": 1065 + hasLight = true 1066 + case "dark": 1067 + hasDark = true 1068 + default: 1069 + continue 1070 + } 1071 + } 1072 + 1073 + return (hasLight && hasDark) ? .dual : .single 1074 + } 1075 + 1076 + private static func classifyBackgroundTone(from spec: String?) -> GhosttyTerminalTone { 1077 + // Decide "light or dark" purely from luminance. Popular dark themes 1078 + // (Dracula, Nord, One Dark, Kanagawa, Solarized Dark, etc.) often have 1079 + // noticeably tinted backgrounds, so gating on saturation misclassifies 1080 + // them as unknown and defeats the whole fallback. 1081 + guard let spec, let color = NSColor(ghosttyHexColor: spec) else { return .unknown } 1082 + 1083 + let luminance = color.luminance 1084 + if luminance >= 0.65 { 1085 + return .light 1086 + } 1087 + if luminance <= 0.35 { 1088 + return .dark 1089 + } 1090 + return .unknown 1091 + } 1092 + } 1093 + 747 1094 extension Notification.Name { 748 1095 static let ghosttyRuntimeConfigDidChange = Notification.Name("ghosttyRuntimeConfigDidChange") 749 1096 } ··· 753 1100 luminance > 0.5 754 1101 } 755 1102 756 - var luminance: Double { 1103 + nonisolated var luminance: Double { 757 1104 var red: CGFloat = 0 758 1105 var green: CGFloat = 0 759 1106 var blue: CGFloat = 0 ··· 761 1108 guard let rgb = usingColorSpace(.sRGB) else { return 0 } 762 1109 rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) 763 1110 return (0.299 * red) + (0.587 * green) + (0.114 * blue) 1111 + } 1112 + 1113 + nonisolated fileprivate convenience init?(ghosttyHexColor: String) { 1114 + let cleaned = ghosttyHexColor 1115 + .trimmingCharacters(in: .whitespacesAndNewlines) 1116 + .replacingOccurrences(of: "#", with: "") 1117 + guard cleaned.count == 6, let value = Int(cleaned, radix: 16) else { 1118 + return nil 1119 + } 1120 + 1121 + let red = Double((value >> 16) & 0xFF) / 255 1122 + let green = Double((value >> 8) & 0xFF) / 255 1123 + let blue = Double(value & 0xFF) / 255 1124 + self.init(red: red, green: green, blue: blue, alpha: 1) 764 1125 } 765 1126 766 1127 fileprivate convenience init(ghostty: ghostty_config_color_s) {
+50
supacodeTests/GhosttyUserConfigSnapshotTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + struct GhosttyUserConfigSnapshotTests { 6 + @Test func detectsDualTheme() { 7 + let snapshot = GhosttyUserConfigSnapshot.parse(showConfigOutput: """ 8 + theme = light:Catppuccin Latte,dark:Catppuccin Frappe 9 + background = #1f1f28 10 + """) 11 + 12 + #expect(snapshot.themeMode == .dual) 13 + } 14 + 15 + @Test func detectsSingleTheme() { 16 + let snapshot = GhosttyUserConfigSnapshot.parse(showConfigOutput: """ 17 + theme = kanagawabones 18 + background = #f2f2f2 19 + """) 20 + 21 + #expect(snapshot.themeMode == .single) 22 + } 23 + 24 + @Test func detectsUnsetTheme() { 25 + let snapshot = GhosttyUserConfigSnapshot.parse(showConfigOutput: """ 26 + background = #1f1f28 27 + """) 28 + 29 + #expect(snapshot.themeMode == .none) 30 + } 31 + 32 + @Test func classifiesBackgroundToneLightDarkUnknown() { 33 + let dark = GhosttyUserConfigSnapshot.parse(showConfigOutput: "background = #1a1a1a") 34 + #expect(dark.backgroundTone == .dark) 35 + 36 + let light = GhosttyUserConfigSnapshot.parse(showConfigOutput: "background = #f4f4f4") 37 + #expect(light.backgroundTone == .light) 38 + 39 + // Popular tinted dark backgrounds should still classify as dark. 40 + let kanagawa = GhosttyUserConfigSnapshot.parse(showConfigOutput: "background = #1f1f28") 41 + #expect(kanagawa.backgroundTone == .dark) 42 + 43 + let solarizedDark = GhosttyUserConfigSnapshot.parse(showConfigOutput: "background = #002b36") 44 + #expect(solarizedDark.backgroundTone == .dark) 45 + 46 + // Mid-luminance colors remain ambiguous and must not trigger a fallback. 47 + let mid = GhosttyUserConfigSnapshot.parse(showConfigOutput: "background = #808080") 48 + #expect(mid.backgroundTone == .unknown) 49 + } 50 + }