From 7ffdda2332d08d8ce95f2b9bd5ab72404c5d09af Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 26 Jun 2026 09:23:28 +0800 Subject: [PATCH] terminal: follow Ghostty split light/dark themes per system appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty resolves `theme = light:X,dark:Y` to one set of colors at config finalize, keyed on a private conditional state that defaults to light, with no C API to query the other variant. Mori never pushed a color scheme or observed the system appearance, so split themes were stuck on the light variant for both the terminal and Mori's own chrome. - GhosttyApp/GhosttyAdapter: push the color scheme to libghostty (app + every surface, and on surface creation) so the terminal re-resolves the split theme live; resolve the active variant ourselves and re-extract chrome colors via a forced single-theme extraction config. - AppDelegate: observe NSApp.effectiveAppearance and re-run theme propagation (factored into propagateGhosttyTheme) on system dark/light changes. - Settings: model gains syncAppearance + darkTheme; read/write parse and emit the light:…,dark:… syntax. Theme UI adds a "sync with system appearance" toggle with light/dark variant pickers. - Docs + en/zh localization updated. --- AGENTS.md | 10 ++- CHANGELOG.md | 4 + CHANGELOG.zh-Hans.md | 4 + .../Sources/MoriTerminal/GhosttyAdapter.swift | 14 +++ .../Sources/MoriTerminal/GhosttyApp.swift | 57 +++++++++++- .../MoriTerminal/GhosttyColorScheme.swift | 78 ++++++++++++++++ .../MoriTerminal/GhosttyConfigWriter.swift | 13 +++ .../Sources/MoriUI/GhosttySettingsView.swift | 89 +++++++++++++++++-- .../Resources/en.lproj/Localizable.strings | 8 ++ .../zh-Hans.lproj/Localizable.strings | 8 ++ Sources/Mori/App/AppDelegate.swift | 37 +++++++- 11 files changed, 303 insertions(+), 19 deletions(-) create mode 100644 Packages/MoriTerminal/Sources/MoriTerminal/GhosttyColorScheme.swift diff --git a/AGENTS.md b/AGENTS.md index 3130979f..483e1ad9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,13 +49,17 @@ Always build release before tagging. ## Theme / Appearance All windows and panels **must** sync their appearance with the Ghostty terminal theme. -The theme is resolved at startup via `GhosttyThemeInfo` (from `MoriTerminal`) and updated -on config reload in `AppDelegate.reloadGhosttyConfig()`. +The theme is resolved at startup via `GhosttyThemeInfo` (from `MoriTerminal`) and re-applied +through `AppDelegate.propagateGhosttyTheme(adapter:)` — called both on config reload +(`reloadGhosttyConfig()`) and on system dark/light changes (`handleSystemAppearanceChange()`). +Split themes (`theme = light:…,dark:…`) resolve per appearance: Mori pushes the color scheme +to libghostty via `GhosttyAdapter.setColorScheme(_:)` and re-extracts chrome colors for the +matching variant (libghostty exposes no API to query the non-active variant from a config). When adding a new `NSWindow` or `NSPanel`: 1. Set `window.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua)` 2. Set `window.backgroundColor = themeInfo.background` -3. Add an `updateAppearance(themeInfo:)` method and call it from `reloadGhosttyConfig()` +3. Add an `updateAppearance(themeInfo:)` method and call it from `propagateGhosttyTheme(adapter:)` SwiftUI views inside `NSHostingView` automatically inherit the window's `NSAppearance`, so semantic colors like `Color.primary`, `Color(nsColor: .controlBackgroundColor)`, and diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9bed1c..ff4788ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### ✨ Features + +- **macOS**: Mori now follows Ghostty's split light/dark themes. Set `theme = light:…,dark:…` in your Ghostty config (or toggle **Sync with system appearance** in Settings → Theme and pick a light and a dark theme) and the terminal *and* Mori's own chrome — sidebar, windows, panels, tmux — switch live when you change the macOS appearance. + ## [0.5.1] - 2026-06-25 ### 🐛 Bug Fixes diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index f9826263..df19b2cf 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,10 @@ ## [Unreleased] +### ✨ 新功能 + +- **macOS**:Mori 现在支持 Ghostty 的浅色 / 深色双主题。在 Ghostty 配置里写 `theme = light:…,dark:…`(或在 设置 → 主题 打开 **跟随系统外观** 并分别选好浅色、深色主题),切换 macOS 外观时,终端与 Mori 自身的 chrome——侧栏、窗口、面板、tmux——都会实时跟随切换。 + ## [0.5.1] - 2026-06-25 ### 🐛 问题修复 diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift index d842d0b8..f3f94393 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift @@ -35,6 +35,16 @@ public final class GhosttyAdapter: TerminalHost { GhosttyApp.shared.reloadConfig() } + /// Switch the active light/dark color scheme. Pushes it to the app and every + /// live surface so split themes re-resolve, and re-extracts chrome colors. + /// Read `themeInfo` afterwards to repaint Mori's own UI. + public func setColorScheme(_ scheme: GhosttyColorScheme) { + GhosttyApp.shared.setColorScheme(scheme) + for surface in surfaces.values { + ghostty_surface_set_color_scheme(surface, scheme.cValue) + } + } + /// Apply Ghostty-derived window translucency and blur to the main workspace window. public func syncWorkspaceWindowAppearance(_ window: NSWindow) { applyWindowAppearance(window, allowsTransparency: true) @@ -104,6 +114,10 @@ public final class GhosttyAdapter: TerminalHost { surfaceView.ghosttySurface = surface surfaces[ObjectIdentifier(surfaceView)] = surface + // Match the surface to the current color scheme so a split theme resolves + // to the right variant from the start (new surfaces default to light). + ghostty_surface_set_color_scheme(surface, GhosttyApp.shared.colorScheme.cValue) + // Register in app's surface registry for clipboard callbacks let userdata = Unmanaged.passUnretained(surfaceView).toOpaque() GhosttyApp.shared.registerSurface(surface, userdata: userdata) diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift index 73db4a84..a1e9c9af 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift @@ -64,6 +64,10 @@ final class GhosttyApp { /// Theme colors resolved from the ghostty config at startup. private(set) var themeInfo: GhosttyThemeInfo = .fallback + /// The color scheme (light/dark) the app is currently resolving themes for. + /// Drives split-theme resolution for both libghostty and Mori's chrome colors. + private(set) var colorScheme: GhosttyColorScheme = .light + /// Callback for ghostty actions that Mori intercepts (tabs, splits, etc.). /// Set by the app target to redirect ghostty intents to WorkspaceManager/tmux. var actionHandler: (@MainActor (GhosttyAppAction) -> Void)? @@ -82,14 +86,19 @@ final class GhosttyApp { return } + // Resolve the initial color scheme from the system appearance so split + // themes start on the correct variant. + self.colorScheme = GhosttyColorScheme.system + // Build config: user's ghostty config + Mori overrides guard let config = buildConfig() else { NSLog("[GhosttyApp] failed to create config") return } - // Extract theme info before the config is consumed by ghostty_app_new - self.themeInfo = GhosttyThemeInfo.from(config: config) + // Extract chrome theme colors for the active appearance (resolving any + // split light/dark theme ourselves). Independent of the app config below. + self.themeInfo = extractThemeInfo(for: colorScheme) // Build runtime config in nonisolated context so closures don't // inherit @MainActor isolation (they're called from renderer thread). @@ -107,6 +116,10 @@ final class GhosttyApp { // Set initial focus state ghostty_app_set_focus(app, NSApp.isActive) + + // Push the resolved color scheme so libghostty's conditional state (and + // any surfaces created later) match the system appearance. + ghostty_app_set_color_scheme(app, colorScheme.cValue) } // Singleton lives for app lifetime — no deinit needed. @@ -132,7 +145,12 @@ final class GhosttyApp { // MARK: - Config /// Build a ghostty config: load user's config first, then apply Mori overrides. - func buildConfig() -> ghostty_config_t? { + /// + /// - Parameter forcingTheme: when non-nil, a single theme name appended last so it + /// overrides the user's `theme`. Used only for chrome color extraction of a + /// resolved split-theme variant; the terminal's own config passes nil to keep + /// the split theme intact so libghostty can switch variants live. + func buildConfig(forcingTheme: String? = nil) -> ghostty_config_t? { guard let config = ghostty_config_new() else { return nil } // 1. Load user's ghostty config (standard path) @@ -145,18 +163,49 @@ final class GhosttyApp { let overridePath = GhosttyConfigWriter.write(appSupportDirectory: MoriPaths.appSupportDirectory) ghostty_config_load_file(config, overridePath) + // 3. Optionally force a single resolved theme (split-theme extraction). + if let forcingTheme { + let themePath = GhosttyConfigWriter.writeThemeOverride( + appSupportDirectory: MoriPaths.appSupportDirectory, + theme: forcingTheme + ) + ghostty_config_load_file(config, themePath) + } + ghostty_config_finalize(config) return config } + /// Extract chrome theme colors for `scheme`, resolving a split light/dark theme + /// to the matching variant. Builds a throwaway config since libghostty exposes + /// no way to query the non-default variant from a finalized config. + private func extractThemeInfo(for scheme: GhosttyColorScheme) -> GhosttyThemeInfo { + let userTheme = GhosttyConfigFile().get("theme") + let forced = userTheme.flatMap { GhosttyThemeSpec.resolveSplit($0, scheme: scheme) } + guard let config = buildConfig(forcingTheme: forced) else { return .fallback } + defer { ghostty_config_free(config) } + return GhosttyThemeInfo.from(config: config) + } + /// Reload config from disk and update the running app + extract new theme. /// Call after writing changes to ~/.config/ghostty/config. func reloadConfig() { guard let app else { return } guard let config = buildConfig() else { return } - self.themeInfo = GhosttyThemeInfo.from(config: config) ghostty_app_update_config(app, config) ghostty_config_free(config) + self.themeInfo = extractThemeInfo(for: colorScheme) + } + + /// Update the active color scheme: push it to libghostty (so the terminal + /// re-resolves a split theme) and re-extract Mori's chrome colors. + /// Per-surface propagation is handled by `GhosttyAdapter`. + func setColorScheme(_ scheme: GhosttyColorScheme) { + colorScheme = scheme + if let app { + ghostty_app_set_color_scheme(app, scheme.cValue) + } + self.themeInfo = extractThemeInfo(for: scheme) } // MARK: - Event Loop diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyColorScheme.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyColorScheme.swift new file mode 100644 index 00000000..2e012a07 --- /dev/null +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyColorScheme.swift @@ -0,0 +1,78 @@ +#if os(macOS) +import AppKit +import GhosttyKit + +/// Light/dark color scheme, mirroring ghostty's `ghostty_color_scheme_e`. +public enum GhosttyColorScheme: Sendable { + case light + case dark + + var cValue: ghostty_color_scheme_e { + self == .dark ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT + } + + /// The current system appearance, resolved from `NSApp.effectiveAppearance`. + @MainActor + public static var system: GhosttyColorScheme { + let match = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) + return match == .darkAqua ? .dark : .light + } +} + +/// Parses ghostty's split light/dark `theme` syntax (`theme = light:Foo,dark:Bar`). +/// +/// libghostty resolves a split theme to a single set of colors at config-finalize +/// time based on a private conditional state that defaults to light, and exposes no +/// C API to query the dark variant from a bare config. So to render Mori's own chrome +/// (sidebar, windows, panels, tmux) in the appearance the user is actually in, we +/// resolve the variant name ourselves and force it onto an extraction config. +public enum GhosttyThemeSpec { + /// A parsed light/dark split theme. + public struct Split: Sendable, Equatable { + public var light: String + public var dark: String + + public init(light: String, dark: String) { + self.light = light + self.dark = dark + } + } + + /// Parse a raw `theme` config value as a light/dark split (`light:Foo,dark:Bar`). + /// Returns nil when neither a `light` nor `dark` key is present (single theme). + /// A missing side mirrors the present one so both fields are always populated. + public static func parseSplit(_ rawValue: String) -> Split? { + var light: String? + var dark: String? + + for token in rawValue.split(separator: ",") { + let trimmed = token.trimmingCharacters(in: .whitespaces) + let lower = trimmed.lowercased() + for prefix in ["light:", "light="] where lower.hasPrefix(prefix) { + light = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + for prefix in ["dark:", "dark="] where lower.hasPrefix(prefix) { + dark = String(trimmed.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) + } + } + + guard light != nil || dark != nil else { return nil } + let l = light ?? dark ?? "" + let d = dark ?? light ?? "" + return Split(light: l, dark: d) + } + + /// Build the ghostty `theme` value for a light/dark split. + public static func splitValue(light: String, dark: String) -> String { + "light:\(light),dark:\(dark)" + } + + /// Resolve a raw `theme` config value to a single theme name for `scheme`. + /// Returns nil when the value is not a light/dark split — callers should then + /// use the config's own resolution (single theme or ghostty default). + static func resolveSplit(_ rawValue: String, scheme: GhosttyColorScheme) -> String? { + guard let split = parseSplit(rawValue) else { return nil } + return scheme == .dark ? split.dark : split.light + } +} +#endif diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigWriter.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigWriter.swift index d1445ffb..25e32e97 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigWriter.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigWriter.swift @@ -29,5 +29,18 @@ enum GhosttyConfigWriter { try? content.write(to: configFilePath, atomically: true, encoding: .utf8) return configFilePath.path } + + /// Write a single-theme override used only when extracting chrome colors for a + /// resolved split-theme variant. Returns the file path. Loaded last so it wins + /// over the user's split `theme` value. Not used for the terminal's own config, + /// which keeps the split theme so libghostty can switch variants live. + @discardableResult + static func writeThemeOverride(appSupportDirectory: URL, theme: String) -> String { + let configFilePath = appSupportDirectory.appendingPathComponent("ghostty-mori-theme.conf") + let content = "# Mori resolved-theme override — do not edit manually.\ntheme = \(theme)\n" + try? FileManager.default.createDirectory(at: appSupportDirectory, withIntermediateDirectories: true) + try? content.write(to: configFilePath, atomically: true, encoding: .utf8) + return configFilePath.path + } } #endif diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index d6541430..8d2e8143 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -61,7 +61,12 @@ public enum GhosttyBackgroundBlur: Equatable { public struct GhosttySettingsModel: Equatable { public var fontFamily: String public var fontSize: Int + /// Single theme, or the light variant when `syncAppearance` is on. public var theme: String + /// Dark variant, used only when `syncAppearance` is on. + public var darkTheme: String + /// When on, follow the system appearance: theme = light:,dark:. + public var syncAppearance: Bool public var cursorStyle: String public var cursorBlink: Bool public var backgroundOpacity: Double @@ -78,6 +83,8 @@ public struct GhosttySettingsModel: Equatable { fontFamily: String = "", fontSize: Int = 13, theme: String = "", + darkTheme: String = "", + syncAppearance: Bool = false, cursorStyle: String = "block", cursorBlink: Bool = true, backgroundOpacity: Double = 1.0, @@ -93,6 +100,8 @@ public struct GhosttySettingsModel: Equatable { self.fontFamily = fontFamily self.fontSize = fontSize self.theme = theme + self.darkTheme = darkTheme + self.syncAppearance = syncAppearance self.cursorStyle = cursorStyle self.cursorBlink = cursorBlink self.backgroundOpacity = backgroundOpacity @@ -669,11 +678,15 @@ private struct ThemeSettingsContent: View { var id: String { rawValue } } + private enum ThemeVariant: Hashable { case light, dark } + @Binding var model: GhosttySettingsModel let availableThemes: [String] let onChanged: () -> Void @State private var themeSearch = "" + /// Which variant the shared theme list edits when `syncAppearance` is on. + @State private var editingVariant: ThemeVariant = .light private var blurPreset: Binding { Binding( @@ -726,13 +739,48 @@ private struct ThemeSettingsContent: View { // Theme settings card SettingsCard { SettingRow( - title: .localized("Color theme"), - description: .localized("Select a color scheme for the terminal.") + title: .localized("Sync with system appearance"), + description: .localized("Use separate light and dark themes that follow the macOS appearance.") ) { - Text(model.theme.isEmpty ? .localized("Default") : model.theme) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .frame(width: 160, alignment: .trailing) + Toggle("", isOn: $model.syncAppearance) + .labelsHidden() + .onChange(of: model.syncAppearance) { _, _ in onChanged() } + } + + CardDivider() + + if model.syncAppearance { + SettingRow( + title: .localized("Light theme"), + description: .localized("Used when macOS is in light appearance.") + ) { + themeValueLabel(model.theme) + } + + CardDivider() + + SettingRow( + title: .localized("Dark theme"), + description: .localized("Used when macOS is in dark appearance.") + ) { + themeValueLabel(model.darkTheme) + } + + CardDivider() + + Picker("", selection: $editingVariant) { + Text(String.localized("Light")).tag(ThemeVariant.light) + Text(String.localized("Dark")).tag(ThemeVariant.dark) + } + .pickerStyle(.segmented) + .labelsHidden() + } else { + SettingRow( + title: .localized("Color theme"), + description: .localized("Select a color scheme for the terminal.") + ) { + themeValueLabel(model.theme) + } } CardDivider() @@ -850,9 +898,33 @@ private struct ThemeSettingsContent: View { return availableThemes.filter { $0.lowercased().contains(query) } } + /// The theme name the list currently edits — a specific variant when syncing, + /// otherwise the single theme. + private var selectedThemeName: String { + guard model.syncAppearance else { return model.theme } + return editingVariant == .dark ? model.darkTheme : model.theme + } + + private func selectTheme(_ name: String) { + if model.syncAppearance, editingVariant == .dark { + model.darkTheme = name + } else { + model.theme = name + } + onChanged() + } + + @ViewBuilder + private func themeValueLabel(_ value: String) -> some View { + Text(value.isEmpty ? .localized("Default") : value) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(width: 160, alignment: .trailing) + } + @ViewBuilder private func themeListRow(_ name: String) -> some View { - let isSelected = model.theme.lowercased() == name.lowercased() + let isSelected = selectedThemeName.lowercased() == name.lowercased() HStack { Text(name) .font(.system(size: 12, design: .monospaced)) @@ -869,8 +941,7 @@ private struct ThemeSettingsContent: View { .background(isSelected ? Color.accentColor.opacity(0.1) : .clear) .contentShape(Rectangle()) .onTapGesture { - model.theme = name - onChanged() + selectTheme(name) } } } diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index 4f8c79fb..41783d14 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings @@ -292,3 +292,11 @@ "sent" = "sent"; "Waiting for input" = "Waiting for input"; "now" = "now"; +"Sync with system appearance" = "Sync with system appearance"; +"Use separate light and dark themes that follow the macOS appearance." = "Use separate light and dark themes that follow the macOS appearance."; +"Light theme" = "Light theme"; +"Dark theme" = "Dark theme"; +"Used when macOS is in light appearance." = "Used when macOS is in light appearance."; +"Used when macOS is in dark appearance." = "Used when macOS is in dark appearance."; +"Light" = "Light"; +"Dark" = "Dark"; diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings index 3744cda8..adcdc14d 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings @@ -292,3 +292,11 @@ "sent" = "已发送"; "Waiting for input" = "等待输入"; "now" = "现在"; +"Sync with system appearance" = "跟随系统外观"; +"Use separate light and dark themes that follow the macOS appearance." = "使用随 macOS 外观切换的浅色 / 深色主题。"; +"Light theme" = "浅色主题"; +"Dark theme" = "深色主题"; +"Used when macOS is in light appearance." = "macOS 处于浅色外观时使用。"; +"Used when macOS is in dark appearance." = "macOS 处于深色外观时使用。"; +"Light" = "浅色"; +"Dark" = "深色"; diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 1f2ce073..4629f672 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -34,6 +34,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var remoteConnectWizardController: RemoteConnectWizardController? private var updateController: UpdateController? private var agentDashboardPanel: AgentDashboardPanel? + private var appearanceObserver: NSKeyValueObservation? private var keyBindingStore: KeyBindingStore! private var configurableMenuItems: [String: NSMenuItem] = [:] private var keyMonitorActionMap: [String: () -> Void] = [:] @@ -341,6 +342,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.refreshGhosttyThemeBackgrounds(themeInfo: adapter.themeInfo) } + // Track system dark/light changes so split themes (theme = light:…,dark:…) + // re-resolve for both the terminal and Mori's own chrome. start() already + // applied the initial scheme, so we only react to subsequent changes. + appearanceObserver = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in + MainActor.assumeIsolated { + self?.handleSystemAppearanceChange() + } + } + windowController.contentViewController = splitVC windowController.showWindow(nil) if let adapter = terminalArea.terminalHost as? GhosttyAdapter, @@ -898,10 +908,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func readSettingsModel(from cf: GhosttyConfigFile) -> GhosttySettingsModel { - GhosttySettingsModel( + let rawTheme = cf.get("theme") ?? "" + let split = GhosttyThemeSpec.parseSplit(rawTheme) + return GhosttySettingsModel( fontFamily: cf.get("font-family") ?? "", fontSize: Int(cf.get("font-size") ?? "") ?? 13, - theme: cf.get("theme") ?? "", + theme: split?.light ?? rawTheme, + darkTheme: split?.dark ?? "", + syncAppearance: split != nil, cursorStyle: cf.get("cursor-style") ?? "block", cursorBlink: (cf.get("cursor-style-blink") ?? "true") != "false", backgroundOpacity: Double(cf.get("background-opacity") ?? "1.0") ?? 1.0, @@ -926,7 +940,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } cf.set("font-size", value: "\(model.fontSize)") - if model.theme.isEmpty { + if model.syncAppearance, !model.theme.isEmpty || !model.darkTheme.isEmpty { + // Mirror a missing side so both variants are always valid theme names. + let light = model.theme.isEmpty ? model.darkTheme : model.theme + let dark = model.darkTheme.isEmpty ? model.theme : model.darkTheme + cf.set("theme", value: GhosttyThemeSpec.splitValue(light: light, dark: dark)) + } else if model.theme.isEmpty { cf.remove("theme") } else { cf.set("theme", value: model.theme) @@ -948,7 +967,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func reloadGhosttyConfig() { guard let adapter = terminalAreaController?.terminalHost as? GhosttyAdapter else { return } adapter.reloadConfig() + propagateGhosttyTheme(adapter: adapter) + } + + /// React to a macOS appearance (dark/light) change: push the new color scheme to + /// ghostty so split themes re-resolve, then repaint Mori's chrome to match. + private func handleSystemAppearanceChange() { + guard let adapter = terminalAreaController?.terminalHost as? GhosttyAdapter else { return } + adapter.setColorScheme(.system) + propagateGhosttyTheme(adapter: adapter) + } + /// Sync the current ghostty theme colors to the window, sidebar, panels, and tmux. + private func propagateGhosttyTheme(adapter: GhosttyAdapter) { let themeInfo = adapter.themeInfo if let window = mainWindowController?.window { adapter.syncWorkspaceWindowAppearance(window)