Skip to content
Draft
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### ✨ Features

- Settings → Theme now supports appearance-aware Ghostty theme selection, so you can configure separate light and dark themes directly in Mori and persist them in Ghostty syntax like `theme = light:Ayu Light,dark:Ayu` while keeping live terminal + chrome reload behavior

### 🎨 Design

- Derive Mori’s app chrome from the selected Ghostty theme instead of reusing the raw terminal background everywhere, giving light themes stronger surface hierarchy across the main window, sidebar, settings window, companion pane, worktree creation panel, agent dashboard, and sidebar row states while keeping Ghostty-driven dark/light mode in sync

### 🐛 Bug Fixes

- Quote Ghostty setting values that contain spaces and collapse duplicate singleton keys when saving from Settings, so changing themes like `Ayu Light` actually updates the running terminal and Mori chrome instead of silently leaving the old theme active

## [0.4.1] - 2026-04-19

### ✨ Features
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@

## [Unreleased]

### ✨ 新功能

- 设置 → Theme 现在支持直接配置跟随外观切换的 Ghostty 主题:可在 Mori 内分别选择浅色 / 深色主题,并以 `theme = light:Ayu Light,dark:Ayu` 这样的 Ghostty 语法持久化,同时继续保持终端与应用外壳的实时刷新

### 🎨 界面优化

- Mori 现在会从所选 Ghostty 主题派生应用外壳配色,而不再把原始终端背景直接铺到整套界面上;这样浅色主题在主窗口、侧边栏、设置窗口、右侧工具面板、工作树创建面板、Agent Dashboard 以及侧边栏行状态上都有了更清晰的层级,同时仍保持 Ghostty 驱动的深浅模式同步

### 🐛 问题修复

- 在设置里保存 Ghostty 配置时,现会对包含空格的值自动加引号,并清理重复的单值键;这样像 `Ayu Light` 这类主题切换才会真正应用到正在运行的终端和 Mori 外壳,而不会悄悄停留在旧主题上

## [0.4.1] - 2026-04-19

### ✨ 新功能
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public final class GhosttyAdapter: TerminalHost {
GhosttyApp.shared.reloadConfig()
}

/// Sync Ghostty's light/dark conditional theme branch to the current system appearance.
public func setColorScheme(isDark: Bool) {
GhosttyApp.shared.setColorScheme(isDark: isDark)
}

/// Apply Ghostty-derived window translucency and blur to the main workspace window.
public func syncWorkspaceWindowAppearance(_ window: NSWindow) {
applyWindowAppearance(window, allowsTransparency: true)
Expand Down
60 changes: 56 additions & 4 deletions Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ final class GhosttyApp {
return
}

// Extract theme info before the config is consumed by ghostty_app_new
self.themeInfo = GhosttyThemeInfo.from(config: config)
// Extract theme info using the active appearance-specific theme branch.
self.themeInfo = resolvedThemeInfo(forDarkAppearance: currentSystemAppearanceIsDark)

// Build runtime config in nonisolated context so closures don't
// inherit @MainActor isolation (they're called from renderer thread).
Expand Down Expand Up @@ -132,7 +132,7 @@ final class GhosttyApp {
// MARK: - Config

/// Build a ghostty config: load user's config first, then apply Mori overrides.
func buildConfig() -> ghostty_config_t? {
func buildConfig(themeOverride: 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,6 +145,15 @@ final class GhosttyApp {
let overridePath = GhosttyConfigWriter.write(appSupportDirectory: MoriPaths.appSupportDirectory)
ghostty_config_load_file(config, overridePath)

// 3. For Mori chrome theme derivation, optionally force the active
// theme branch so paired light/dark themes resolve deterministically.
if let themeOverride,
!themeOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let temporaryOverridePath = writeTemporaryThemeOverrideFile(themeOverride) {
ghostty_config_load_file(config, temporaryOverridePath)
try? FileManager.default.removeItem(atPath: temporaryOverridePath)
}

ghostty_config_finalize(config)
return config
}
Expand All @@ -154,11 +163,54 @@ final class GhosttyApp {
func reloadConfig() {
guard let app else { return }
guard let config = buildConfig() else { return }
self.themeInfo = GhosttyThemeInfo.from(config: config)
self.themeInfo = resolvedThemeInfo(forDarkAppearance: currentSystemAppearanceIsDark)
ghostty_app_update_config(app, config)
ghostty_config_free(config)
}

/// Update the running Ghostty color scheme for system light/dark changes
/// without rewriting the user's config.
func setColorScheme(isDark: Bool) {
guard let app else { return }
self.themeInfo = resolvedThemeInfo(forDarkAppearance: isDark)
applyColorScheme(to: app, isDark: isDark)
}

private var currentSystemAppearanceIsDark: Bool {
let bestMatch = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])
return bestMatch == .darkAqua
}

private func resolvedThemeInfo(forDarkAppearance isDark: Bool) -> GhosttyThemeInfo {
let themeOverride = GhosttyConfigFile.resolvedThemeValue(forDarkAppearance: isDark)
guard let config = buildConfig(themeOverride: themeOverride) else { return .fallback }
let info = GhosttyThemeInfo.from(config: config)
ghostty_config_free(config)
return info
}

private func applyColorScheme(to app: ghostty_app_t, isDark: Bool) {
let scheme: ghostty_color_scheme_e = isDark ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT
ghostty_app_set_color_scheme(app, scheme)
}

private func writeTemporaryThemeOverrideFile(_ theme: String) -> String? {
let sanitized = theme.trimmingCharacters(in: .whitespacesAndNewlines)
guard !sanitized.isEmpty else { return nil }

let escaped = sanitized.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let content = "theme = \"\(escaped)\"\n"
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent("mori-ghostty-theme-override-\(UUID().uuidString).conf")
do {
try content.write(toFile: path, atomically: true, encoding: .utf8)
return path
} catch {
NSLog("[GhosttyApp] failed to write temporary theme override: \(error)")
return nil
}
}

// MARK: - Event Loop

func tick() {
Expand Down
74 changes: 65 additions & 9 deletions Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,24 @@ public final class GhosttyConfigFile {

// MARK: - Write

/// Set a config value. Updates existing key or appends if new.
/// Set a config value. Updates the first occurrence, removes later duplicates,
/// or appends if the key does not exist.
public func set(_ key: String, value: String) {
// Update first occurrence
for i in lines.indices {
if case .keyValue(let k, _, _) = lines[i], k == key {
lines[i] = .keyValue(key, value, "\(key) = \(value)")
return
let rendered = renderKeyValue(key: key, value: value)
var didUpdate = false

lines = lines.compactMap { line in
guard case .keyValue(let k, _, _) = line, k == key else { return line }
if !didUpdate {
didUpdate = true
return .keyValue(key, value, rendered)
}
return nil
}

if !didUpdate {
lines.append(.keyValue(key, value, rendered))
}
// Append new key
lines.append(.keyValue(key, value, "\(key) = \(value)"))
}

/// Remove a key from the config.
Expand Down Expand Up @@ -148,8 +155,57 @@ public final class GhosttyConfigFile {
}
// Append new entries
for value in values {
lines.append(.keyValue(key, value, "\(key) = \(value)"))
lines.append(.keyValue(key, value, renderKeyValue(key: key, value: value)))
}
}

private func renderKeyValue(key: String, value: String) -> String {
let needsQuotes = value.contains { $0.isWhitespace || $0 == "#" || $0 == "\"" }
guard needsQuotes else { return "\(key) = \(value)" }

let escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\(key) = \"\(escaped)\""
}

/// Resolve the active theme value for a given appearance.
/// For paired values like `light:Foo,dark:Bar`, returns the matching side.
/// For single values, returns the value as-is.
public static func resolvedThemeValue(forDarkAppearance isDark: Bool) -> String? {
let config = GhosttyConfigFile()
guard let rawThemeValue = config.get("theme")?.trimmingCharacters(in: .whitespacesAndNewlines),
!rawThemeValue.isEmpty else {
return nil
}

guard let pair = parseAppearanceAwareThemeValue(rawThemeValue) else {
return rawThemeValue
}

let selectedTheme = isDark ? pair.dark : pair.light
return selectedTheme.isEmpty ? nil : selectedTheme
}

private static func parseAppearanceAwareThemeValue(_ value: String) -> (light: String, dark: String)? {
var lightTheme = ""
var darkTheme = ""
var foundAppearanceToken = false

for component in value.split(separator: ",", omittingEmptySubsequences: true) {
let token = component.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercasedToken = token.lowercased()
if lowercasedToken.hasPrefix("light:") {
foundAppearanceToken = true
lightTheme = String(token.dropFirst("light:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
} else if lowercasedToken.hasPrefix("dark:") {
foundAppearanceToken = true
darkTheme = String(token.dropFirst("dark:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
} else {
return nil
}
}

return foundAppearanceToken ? (lightTheme, darkTheme) : nil
}

/// List ghostty default keybindings by running `ghostty +list-keybinds`.
Expand Down
5 changes: 3 additions & 2 deletions Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public struct AgentWindowRowView: View {
let onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)?
let onSendKeys: ((String, String) -> Void)?

@EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore
@State private var isHovered = false
@State private var showPopover = false
@State private var popoverOutput: String?
Expand Down Expand Up @@ -194,9 +195,9 @@ public struct AgentWindowRowView: View {

private var rowBackground: some ShapeStyle {
if isSelected {
return AnyShapeStyle(MoriTokens.Color.active.opacity(MoriTokens.Opacity.subtle))
return AnyShapeStyle(MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette))
} else if isHovered {
return AnyShapeStyle(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle))
return AnyShapeStyle(MoriTokens.Chrome.rowHoverFill(chromePaletteStore.palette))
} else {
return AnyShapeStyle(Color.clear)
}
Expand Down
47 changes: 47 additions & 0 deletions Packages/MoriUI/Sources/MoriUI/DesignTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,53 @@ public enum MoriTokens {
public static let medium: Double = 0.2
}

// MARK: - Chrome helpers

public enum Chrome {
public static func rowHoverFill(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.hoverFill.color
}

public static func rowSelectionGradient(_ palette: MoriChromePalette) -> LinearGradient {
LinearGradient(
gradient: Gradient(colors: [
palette.rowSelectionLeading.color,
palette.rowSelectionTrailing.color,
]),
startPoint: .leading,
endPoint: .trailing
)
}

public static func rowSelectionFill(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.rowSelectionLeading.color
}

public static func iconBackground(selected: Bool, palette: MoriChromePalette) -> SwiftUI.Color {
selected ? palette.rowSelectionLeading.color : palette.inactiveIconFill.color
}

public static func shortcutPillFill(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.shortcutPillFill.color
}

public static func cardBackground(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.cardBackground.color
}

public static func divider(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.divider.color
}

public static func strongSelectionFill(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.strongSelectionFill.color
}

public static func selectionAccent(_ palette: MoriChromePalette) -> SwiftUI.Color {
palette.selectionAccent.color
}
}

// MARK: - Project Avatar Palette

/// Seven warm/cool duotone pairs (background + foreground) for project letter
Expand Down
Loading
Loading