Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

## [Unreleased]

### ✨ 新功能

- **macOS**:Mori 现在支持 Ghostty 的浅色 / 深色双主题。在 Ghostty 配置里写 `theme = light:…,dark:…`(或在 设置 → 主题 打开 **跟随系统外观** 并分别选好浅色、深色主题),切换 macOS 外观时,终端与 Mori 自身的 chrome——侧栏、窗口、面板、tmux——都会实时跟随切换。

## [0.5.1] - 2026-06-25

### 🐛 问题修复
Expand Down
14 changes: 14 additions & 0 deletions Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 53 additions & 4 deletions Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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).
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading