Add configurable sidebar tint color with light/dark mode support (#1465)

- Config: sidebar-background supports plain hex (#336699) or
  light/dark syntax (light:#fbf3db,dark:#103c48)
- Config: sidebar-tint-opacity overrides tint opacity
- Settings UI: per-scheme color pickers, opacity slider (0-70%), reset
- SidebarBackdrop resolves light/dark hex based on @Environment colorScheme
- applySidebarAppearanceToUserDefaults guards on rawSidebarBackground presence
  so UI picks survive appearance toggles when no config is set
- Stale light/dark UserDefaults keys cleared when config switches from
  dual-mode to single or sidebar-background is removed
- applyPreset() and Reset Tint clear per-scheme overrides
- Debug snapshot (combinedPayload + copySidebarConfig) includes new keys
- ColorPicker labels use String(localized:) per localization policy
- Opacity slider capped at 0.7 to match debug view vibrancy constraint

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ragnar Rova 2026-03-15 23:48:57 +01:00 committed by GitHub
parent 9bb2816e05
commit a7cb968a55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1826 additions and 6 deletions

View file

@ -5,6 +5,7 @@ All notable changes to cmux are documented here.
## [0.62.2] - 2026-03-14
### Added
- Configurable sidebar tint color with separate light/dark mode support via Settings and config file (`sidebar-background`, `sidebar-tint-opacity`) ([#1465](https://github.com/manaflow-ai/cmux/pull/1465))
- Cmd+P all-surfaces search option ([#1382](https://github.com/manaflow-ai/cmux/pull/1382))
- `cmux themes` command with bundled Ghostty themes ([#1334](https://github.com/manaflow-ai/cmux/pull/1334), [#1314](https://github.com/manaflow-ai/cmux/pull/1314))
- Sidebar can now shrink to smaller widths ([#1420](https://github.com/manaflow-ai/cmux/pull/1420))

File diff suppressed because it is too large Load diff

View file

@ -12559,19 +12559,30 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable {
}
private struct SidebarBackdrop: View {
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial)
let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow
let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active
let tintColor = (NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity)
let resolvedHex: String = {
if colorScheme == .dark, let dark = sidebarTintHexDark {
return dark
} else if colorScheme == .light, let light = sidebarTintHexLight {
return light
}
return sidebarTintHex
}()
let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity)
let cornerRadius = CGFloat(max(0, sidebarCornerRadius))
let useLiquidGlass = materialOption?.usesLiquidGlass ?? false
let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow
@ -12706,6 +12717,11 @@ enum SidebarStateOption: String, CaseIterable, Identifiable {
}
}
enum SidebarTintDefaults {
static let hex = "#000000"
static let opacity = 0.18
}
enum SidebarPresetOption: String, CaseIterable, Identifiable {
case nativeSidebar
case glassBehind

View file

@ -29,6 +29,13 @@ struct GhosttyConfig {
var selectionBackground: NSColor = NSColor(hex: "#57584f")!
var selectionForeground: NSColor = NSColor(hex: "#fdfff1")!
// Sidebar appearance
var rawSidebarBackground: String?
var sidebarBackground: NSColor?
var sidebarBackgroundLight: NSColor?
var sidebarBackgroundDark: NSColor?
var sidebarTintOpacity: Double?
// Palette colors (0-15)
var palette: [Int: NSColor] = [:]
@ -134,6 +141,54 @@ struct GhosttyConfig {
return []
}
mutating func resolveSidebarBackground(preferredColorScheme: ColorSchemePreference) {
guard let raw = rawSidebarBackground else { return }
let lightResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .light)
let darkResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .dark)
let hasDualMode = lightResolved != darkResolved
if hasDualMode {
sidebarBackgroundLight = NSColor(hex: lightResolved)
sidebarBackgroundDark = NSColor(hex: darkResolved)
}
let resolved = Self.resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme)
if let color = NSColor(hex: resolved) {
sidebarBackground = color
}
}
func applySidebarAppearanceToUserDefaults() {
guard rawSidebarBackground != nil else {
if let opacity = sidebarTintOpacity {
UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity")
}
return
}
let defaults = UserDefaults.standard
if let light = sidebarBackgroundLight {
defaults.set(light.hexString(), forKey: "sidebarTintHexLight")
} else {
defaults.removeObject(forKey: "sidebarTintHexLight")
}
if let dark = sidebarBackgroundDark {
defaults.set(dark.hexString(), forKey: "sidebarTintHexDark")
} else {
defaults.removeObject(forKey: "sidebarTintHexDark")
}
if let color = sidebarBackground {
defaults.set(color.hexString(), forKey: "sidebarTintHex")
} else {
defaults.removeObject(forKey: "sidebarTintHex")
}
if let opacity = sidebarTintOpacity {
defaults.set(opacity, forKey: "sidebarTintOpacity")
}
}
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
var config = GhosttyConfig()
@ -161,6 +216,9 @@ struct GhosttyConfig {
)
}
config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme)
config.applySidebarAppearanceToUserDefaults()
return config
}
@ -240,6 +298,12 @@ struct GhosttyConfig {
if let color = NSColor(hex: value) {
splitDividerColor = color
}
case "sidebar-background":
rawSidebarBackground = value
case "sidebar-tint-opacity":
if let opacity = Double(value) {
sidebarTintOpacity = min(max(opacity, 0), 1)
}
default:
break
}

View file

@ -1526,6 +1526,8 @@ private enum DebugWindowConfigSnapshot {
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)"))
sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)"))
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
@ -2153,8 +2155,10 @@ private struct AboutPanelView: View {
private struct SidebarDebugView: View {
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@ -2308,7 +2312,9 @@ private struct SidebarDebugView: View {
HStack(spacing: 12) {
Button("Reset Tint") {
sidebarTintOpacity = 0.62
sidebarTintHex = "#000000"
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintHexLight = nil
sidebarTintHexDark = nil
}
Button("Reset Blur") {
sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue
@ -2389,6 +2395,8 @@ private struct SidebarDebugView: View {
sidebarState=\(sidebarState)
sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity))
sidebarTintHex=\(sidebarTintHex)
sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)")
sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)")
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
@ -2416,6 +2424,8 @@ private struct SidebarDebugView: View {
sidebarTintOpacity = preset.tintOpacity
sidebarCornerRadius = preset.cornerRadius
sidebarBlurOpacity = preset.blurOpacity
sidebarTintHexLight = nil
sidebarTintHexDark = nil
}
}
@ -3108,6 +3118,10 @@ struct SettingsView: View {
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@ -3182,6 +3196,30 @@ struct SettingsView: View {
)
}
private var settingsSidebarTintLightBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHexLight ?? sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHexLight = nsColor.hexString()
}
)
}
private var settingsSidebarTintDarkBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHexDark ?? sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHexDark = nsColor.hexString()
}
)
}
private var hasSocketPasswordConfigured: Bool {
SocketControlPasswordStore.hasConfiguredPassword()
}
@ -3939,6 +3977,83 @@ struct SettingsView: View {
}
}
SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"))
SettingsCard {
SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"),
subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.")
) {
HStack(spacing: 8) {
ColorPicker(
String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"),
selection: settingsSidebarTintLightBinding,
supportsOpacity: false
)
.labelsHidden()
.frame(width: 38)
Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 76, alignment: .trailing)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"),
subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.")
) {
HStack(spacing: 8) {
ColorPicker(
String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"),
selection: settingsSidebarTintDarkBinding,
supportsOpacity: false
)
.labelsHidden()
.frame(width: 38)
Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 76, alignment: .trailing)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"),
subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.")
) {
HStack(spacing: 8) {
Slider(value: $sidebarTintOpacity, in: 0...1)
.frame(width: 140)
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 36, alignment: .trailing)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"),
subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.")
) {
Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) {
sidebarTintHexLight = nil
sidebarTintHexDark = nil
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintOpacity = SidebarTintDefaults.opacity
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"))
SettingsCard {
SettingsPickerRow(
@ -4503,6 +4618,10 @@ struct SettingsView: View {
sidebarShowLog = true
sidebarShowProgress = true
sidebarShowMetadata = true
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintHexLight = nil
sidebarTintHexDark = nil
sidebarTintOpacity = SidebarTintDefaults.opacity
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""

View file

@ -1614,6 +1614,157 @@ final class GhosttyMouseFocusTests: XCTestCase {
}
}
final class SidebarBackgroundConfigTests: XCTestCase {
func testParseSidebarBackgroundSingleHex() {
var config = GhosttyConfig()
config.parse("sidebar-background = #336699")
XCTAssertEqual(config.rawSidebarBackground, "#336699")
}
func testParseSidebarBackgroundDualMode() {
var config = GhosttyConfig()
config.parse("sidebar-background = light:#fbf3db,dark:#103c48")
XCTAssertEqual(config.rawSidebarBackground, "light:#fbf3db,dark:#103c48")
}
func testParseSidebarTintOpacity() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = 0.4")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.4, accuracy: 0.0001)
}
func testParseSidebarTintOpacityClampedAboveOne() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = 1.5")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 1.0, accuracy: 0.0001)
}
func testParseSidebarTintOpacityClampedBelowZero() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = -0.3")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001)
}
func testResolveSidebarBackgroundSingleHex() {
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
XCTAssertNotNil(config.sidebarBackground)
XCTAssertNil(config.sidebarBackgroundLight)
XCTAssertNil(config.sidebarBackgroundDark)
}
func testResolveSidebarBackgroundDualModeSetsLightAndDark() {
var config = GhosttyConfig()
config.rawSidebarBackground = "light:#fbf3db,dark:#103c48"
config.resolveSidebarBackground(preferredColorScheme: .light)
XCTAssertNotNil(config.sidebarBackgroundLight)
XCTAssertNotNil(config.sidebarBackgroundDark)
XCTAssertNotNil(config.sidebarBackground)
}
func testResolveSidebarBackgroundNilWhenNoRaw() {
var config = GhosttyConfig()
config.resolveSidebarBackground(preferredColorScheme: .dark)
XCTAssertNil(config.sidebarBackground)
XCTAssertNil(config.sidebarBackgroundLight)
XCTAssertNil(config.sidebarBackgroundDark)
}
func testApplyToUserDefaultsSkipsWritesWhenNoConfig() {
let defaults = UserDefaults.standard
let testKey = "sidebarTintHex"
let original = defaults.string(forKey: testKey)
defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) }
defaults.set("#AAAAAA", forKey: testKey)
var config = GhosttyConfig()
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA",
"Should not overwrite UserDefaults when rawSidebarBackground is nil")
}
func testApplyToUserDefaultsWritesHexWhenConfigSet() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#336699")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"))
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"))
}
func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
defaults.set("#AAAAAA", forKey: "sidebarTintHexLight")
defaults.set("#BBBBBB", forKey: "sidebarTintHexDark")
var config = GhosttyConfig()
config.rawSidebarBackground = "#222222"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"),
"Stale light key should be cleared")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"),
"Stale dark key should be cleared")
}
func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
defaults.set(0.18, forKey: "sidebarTintOpacity")
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.double(forKey: "sidebarTintOpacity"), 0.18, accuracy: 0.0001,
"Should not overwrite opacity when config doesn't set sidebar-tint-opacity")
}
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
if let value = value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
final class ZshShellIntegrationHandoffTests: XCTestCase {
func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws {
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true)