Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5645323
feat(sidebar): add configurable workspace font size
austinywang May 26, 2026
b3adb26
Merge remote-tracking branch 'origin/main' into issue-2643-allow-cust…
austinywang May 26, 2026
cd5b056
fix(sidebar): load sidebar font size off main actor
austinywang May 26, 2026
96530e3
merge: sync with main
austinywang May 30, 2026
b29ab4a
fix: address sidebar font review feedback
austinywang May 30, 2026
3e9fab6
feat: add sidebar font size controls
austinywang May 30, 2026
98e86e4
fix: prefer host zig for helper builds
austinywang May 30, 2026
d472eb3
fix: prefer apple silicon zig on mac runners
austinywang May 30, 2026
9a5875a
fix: prebuild ghostty helper in release workflows
austinywang May 30, 2026
e47150b
fix: use homebrew zig on mac ci
austinywang May 30, 2026
1104afa
fix: keep release helper skip arch-valid
austinywang May 30, 2026
1108491
fix: address sidebar font size settings feedback
austinywang May 30, 2026
41d3a45
fix: cancel sidebar font size refresh tasks
austinywang May 30, 2026
0deaee1
test: update cli config help contract
austinywang May 30, 2026
2590b47
feat: add workspace tab bar font size control + fix font-size slider …
austinywang May 30, 2026
50ba58e
Merge remote-tracking branch 'origin/main' into issue-2643-allow-cust…
austinywang May 31, 2026
b4d9cd7
fix: render font-size sliders in CmuxSettingsUI (settings moved to pa…
austinywang May 31, 2026
aa67955
Merge remote-tracking branch 'origin/main' into tab-icon-scaling-wt
austinywang May 31, 2026
e72fe3e
fix: surface font-size save failures in Settings
austinywang Jun 1, 2026
df961f7
fix: localize font-size value label ("%@ pt")
austinywang Jun 1, 2026
bfa4262
fix: seed sidebar font scale from on-disk config at launch
austinywang Jun 1, 2026
b505523
fix: move font-size config disk I/O off the main actor
austinywang Jun 1, 2026
8f1fc6e
fix: serialize font writes; harden config path/parse + stub arch
austinywang Jun 1, 2026
d397be5
fix: strip BOM when parsing Ghostty config at runtime load
austinywang Jun 1, 2026
c1cfd4b
chore: address CodeRabbit nitpicks (host-arch dedupe, localized format)
austinywang Jun 1, 2026
c41daff
fix: render font-size value label via localizedStringWithFormat
austinywang Jun 1, 2026
fdf1876
vendor/bonsplit: scale tab + control icons with tab bar font size
austinywang Jun 1, 2026
9b85a9c
feat: cap tab bar font size at 14pt
austinywang Jun 1, 2026
22f4094
fix: cancel in-flight font-size save before starting a new one
austinywang Jun 1, 2026
04f275f
test: use in-range 14 for tab-bar font loader test (was 16, now clamped)
austinywang Jun 1, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ jobs:
- name: Build universal app (Release)
run: |
set -euo pipefail
xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Release -derivedDataPath build-universal \
CMUX_SKIP_ZIG_BUILD=1 xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Release -derivedDataPath build-universal \
-destination 'generic/platform=macOS' \
-clonedSourcePackagesDirPath .spm-cache \
ARCHS="arm64 x86_64" \
Expand Down
234 changes: 232 additions & 2 deletions CLI/CMUXCLI+Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,36 @@ extension CMUXCLI {
switch subcommand {
case "help":
print(configUsage())
case "get":
guard args.count == 2, let key = canonicalFontSizeKey(args[1]) else {
throw CLIError(message: "Usage: cmux config get <sidebar-font-size|surface-tab-bar-font-size>")
}
try runConfigGetFontSize(forKey: key, jsonOutput: wantsJSON)
case "set":
guard args.count == 3, let key = canonicalFontSizeKey(args[1]) else {
throw CLIError(message: "Usage: cmux config set <sidebar-font-size|surface-tab-bar-font-size> <points>")
}
try runConfigSetFontSize(
forKey: key,
rawValue: args[2],
socketPath: socketPath,
explicitPassword: explicitPassword,
jsonOutput: wantsJSON
)
case CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey, CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey:
if args.count == 1 {
try runConfigGetFontSize(forKey: subcommand, jsonOutput: wantsJSON)
} else if args.count == 2 {
try runConfigSetFontSize(
forKey: subcommand,
rawValue: args[1],
socketPath: socketPath,
explicitPassword: explicitPassword,
jsonOutput: wantsJSON
)
} else {
throw CLIError(message: "Usage: cmux config \(subcommand) [points]")
}
case "path", "paths":
guard args.count == 1 else {
throw CLIError(message: "Usage: cmux config path")
Expand Down Expand Up @@ -62,21 +92,32 @@ extension CMUXCLI {
func configCommandDoesNotNeedSocket(_ commandArgs: [String]) -> Bool {
let parsedArgs = docsSettingsArguments(commandArgs)
let subcommand = parsedArgs.arguments.first?.lowercased() ?? "help"
if subcommand == "get" {
return true
}
if subcommand == CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey
|| subcommand == CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey {
return parsedArgs.arguments.count == 1
}
Comment thread
cursor[bot] marked this conversation as resolved.
return hasHelpRequest(beforeSeparator: parsedArgs.head) ||
["help", "path", "paths", "docs", "documentation", "doctor", "check", "validate"].contains(subcommand)
}

func configUsage() -> String {
return """
Usage: cmux config <doctor|check|validate|path|paths|docs|documentation|reload>
Usage: cmux config <doctor|check|validate|path|paths|docs|documentation|reload|get|set|sidebar-font-size|surface-tab-bar-font-size>

Inspect cmux.json, print configuration references, or reload the running app.
Inspect cmux.json, print configuration references, update selected Ghostty config keys, or reload the running app.

Subcommands:
doctor|check|validate [--path <path>] Validate JSONC syntax for cmux config files.
path|paths Print cmux.json paths, docs URL, and schema URL.
docs|documentation Print the same output as `cmux docs settings`.
reload Reload Ghostty config + cmux.json and refresh terminals (alias for `cmux reload-config`).
get <key> Print sidebar-font-size or surface-tab-bar-font-size.
set <key> <points> Set sidebar-font-size (10-20 pt) or surface-tab-bar-font-size (8-24 pt), then reload if cmux is running.
sidebar-font-size [points] Get or set the left sidebar text size.
surface-tab-bar-font-size [points] Get or set the workspace tab bar text size.

Config files:
\(Self.primarySettingsDisplayPath)
Expand All @@ -89,6 +130,10 @@ extension CMUXCLI {
Examples:
cmux config doctor
cmux config doctor --path .cmux/cmux.json
cmux config set sidebar-font-size 14
cmux config sidebar-font-size 12.5
cmux config set surface-tab-bar-font-size 13
cmux config surface-tab-bar-font-size 11
cmux config reload
"""
}
Expand Down Expand Up @@ -135,6 +180,191 @@ extension CMUXCLI {
print(" cmux reload-config")
}

/// Normalizes a user-supplied key to a supported editable font-size key, or nil if unsupported.
private func canonicalFontSizeKey(_ raw: String) -> String? {
switch raw.lowercased() {
case CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey:
return CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey
case CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey:
return CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey
default:
return nil
}
}

private func fontSizeConfig(
forKey key: String
) -> (defaultValue: Double, clamp: (Double) -> Double, format: (Double) -> String, parse: (String) -> Double?)? {
switch key {
case CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey:
return (
CmuxGhosttyConfigSettingEditor.defaultSidebarFontSize,
CmuxGhosttyConfigSettingEditor.clampedSidebarFontSize,
CmuxGhosttyConfigSettingEditor.formattedSidebarFontSize,
{ CmuxGhosttyConfigSettingEditor.parsedSidebarFontSize(in: $0) }
)
case CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey:
return (
CmuxGhosttyConfigSettingEditor.defaultSurfaceTabBarFontSize,
CmuxGhosttyConfigSettingEditor.clampedSurfaceTabBarFontSize,
CmuxGhosttyConfigSettingEditor.formattedSurfaceTabBarFontSize,
{ CmuxGhosttyConfigSettingEditor.parsedSurfaceTabBarFontSize(in: $0) }
)
default:
return nil
}
}

private func runConfigGetFontSize(forKey key: String, jsonOutput: Bool) throws {
guard let descriptor = fontSizeConfig(forKey: key) else {
throw CLIError(message: "Unknown font size key '\(key)'")
}
let url = try cmuxGhosttyConfigURLForCLI()
let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
let configuredValue = descriptor.parse(contents)
let effectiveValue = configuredValue ?? descriptor.defaultValue
let formattedValue = descriptor.format(effectiveValue)

if jsonOutput {
var payload: [String: Any] = [
"key": key,
"value": effectiveValue,
"formatted": formattedValue,
"path": url.path,
"configured": configuredValue != nil,
]
if let configuredValue {
payload["configured_value"] = configuredValue
}
print(jsonString(payload))
return
}

print("\(key) = \(formattedValue)")
print("path: \(Self.tildePath(url.path))")
}

private func runConfigSetFontSize(
forKey key: String,
rawValue: String,
socketPath: String?,
explicitPassword: String?,
jsonOutput: Bool
) throws {
guard let descriptor = fontSizeConfig(forKey: key) else {
throw CLIError(message: "Unknown font size key '\(key)'")
}
guard let requestedValue = Double(rawValue), requestedValue.isFinite else {
throw CLIError(message: "\(key) requires a numeric point size")
}

let value = descriptor.clamp(requestedValue)
let formattedValue = descriptor.format(value)
let url = try cmuxGhosttyConfigURLForCLI()
try CmuxGhosttyConfigSettingEditor.writeSetting(
key: key,
value: formattedValue,
to: url
)

let reloadResult = reloadConfigAfterFontSizeSet(
socketPath: socketPath,
explicitPassword: explicitPassword
)

if jsonOutput {
var payload: [String: Any] = [
"ok": true,
"key": key,
"value": value,
"formatted": formattedValue,
"path": url.path,
"reload": reloadResult.status,
"clamped": value != requestedValue,
]
if let message = reloadResult.message {
payload["reload_message"] = message
}
print(jsonString(payload))
return
}

switch reloadResult.status {
case "reloaded":
print("OK \(key) = \(formattedValue) (reloaded)")
case "failed":
print("OK \(key) = \(formattedValue) (saved; reload failed)")
if let message = reloadResult.message {
print("reload: \(message)")
}
print("Run `cmux config reload` after cmux is running to apply it.")
default:
print("OK \(key) = \(formattedValue) (saved)")
print("Run `cmux config reload` to apply it.")
}
print("path: \(Self.tildePath(url.path))")
}

private func cmuxGhosttyConfigURLForCLI() throws -> URL {
let environment = ProcessInfo.processInfo.environment
let fileManager = FileManager.default
let appSupportDirectories = CmuxApplicationSupportDirectories
.userDirectories(environment: environment, fileManager: fileManager)
guard let firstAppSupportDirectory = appSupportDirectories.first else {
throw CLIError(message: "Could not resolve the user Application Support directory")
}
let bundleIdentifier = normalizedConfigValue(environment["CMUX_BUNDLE_ID"])
?? CLISocketPathResolver.currentAppBundleIdentifier()
// Prefer an existing config under any candidate root (the app loads config
// across all Application Support locations, including CFFIXED_USER_HOME),
// so `config get/set` touches the same file the app reads. Fall back to
// creating one under the first candidate when none exists yet.
for appSupportDirectory in appSupportDirectories {
Comment thread
austinywang marked this conversation as resolved.
if let existing = CmuxGhosttyConfigPathResolver.loadConfigURLs(
currentBundleIdentifier: bundleIdentifier,
appSupportDirectory: appSupportDirectory,
fileManager: fileManager
).first {
return existing
}
}
return CmuxGhosttyConfigPathResolver.activeOrEditableConfigURL(
currentBundleIdentifier: bundleIdentifier,
appSupportDirectory: firstAppSupportDirectory,
fileManager: fileManager
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

private func reloadConfigAfterFontSizeSet(
socketPath: String?,
explicitPassword: String?
) -> (status: String, message: String?) {
guard let socketPath else {
return ("skipped", nil)
}
do {
let client = try connectClient(
socketPath: socketPath,
explicitPassword: explicitPassword,
launchIfNeeded: false
)
defer { client.close() }
let response = try client.send(command: "reload_config")
if response.hasPrefix("ERROR:") {
return ("failed", response)
}
return ("reloaded", response)
} catch {
return ("failed", Self.configDoctorErrorMessage(error))
}
}

private func normalizedConfigValue(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

private struct ConfigDoctorOptions {
let paths: [String]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation

/// A point size plus the range and default a font-size slider should use.
///
/// Font sizes (the left sidebar, the workspace tab bar) live in the Ghostty
/// config file rather than `UserDefaults`, so the package can't read them
/// through the catalog/``DefaultsValueModel`` path. Instead the host supplies
/// the current value together with its bounds via ``SettingsHostActions``, and
/// the settings UI renders a slider against this descriptor.
///
/// ```swift
/// let font = hostActions.sidebarFontSize()
/// Slider(value: $points, in: font.minimum...font.maximum, step: 0.5)
/// ```
public struct SettingsFontSize: Sendable, Equatable {
/// The current effective size, in points.
public var points: Double

/// The smallest size the slider allows.
public let minimum: Double

/// The largest size the slider allows.
public let maximum: Double

/// The size restored by the row's Reset button.
public let defaultValue: Double

/// Creates a font-size descriptor.
///
/// - Parameters:
/// - points: The current effective size, in points.
/// - minimum: The smallest size the slider allows.
/// - maximum: The largest size the slider allows.
/// - defaultValue: The size restored by the row's Reset button.
public init(points: Double, minimum: Double, maximum: Double, defaultValue: Double) {
self.points = points
self.minimum = minimum
self.maximum = maximum
self.defaultValue = defaultValue
}

/// Whether ``points`` currently matches ``defaultValue`` (within a small
/// tolerance), used to disable the Reset control.
public var isDefault: Bool {
abs(points - defaultValue) < 0.001
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,67 @@ public protocol SettingsHostActions: AnyObject {
/// Browser section uses this to render a dynamic "N saved pages"
/// subtitle next to the Clear History button.
func browserHistoryEntryCount() -> Int?

/// The current left-sidebar font size with the range + default the
/// slider should use. Backed by the Ghostty config file, not
/// `UserDefaults`, so it comes from the host rather than the catalog.
func sidebarFontSize() -> SettingsFontSize

/// Persists a new left-sidebar font size (in points) to the Ghostty
/// config and live-reloads open windows. The host clamps to the valid
/// range, so callers may pass any finite value.
///
/// - Returns: `true` if the value was written and reloaded, `false` if
/// persistence failed. Callers should surface a save-failed message to
/// the user when this returns `false`, since the slider position no
/// longer reflects what is stored on disk.
///
/// The implementation performs the disk write off the main actor, so this
/// is `async`; call it from a `Task` in the slider/reset action.
@discardableResult
func setSidebarFontSize(_ points: Double) async -> Bool

/// The current workspace tab-bar font size with its range + default.
/// Backed by the Ghostty config file (`surface-tab-bar-font-size`).
func surfaceTabBarFontSize() -> SettingsFontSize

/// Persists a new workspace tab-bar font size (in points) and reloads.
/// The host clamps to the valid range.
///
/// - Returns: `true` if the value was written and reloaded, `false` if
/// persistence failed. See ``setSidebarFontSize(_:)`` for how callers
/// should react to a `false` result and why this is `async`.
@discardableResult
func setSurfaceTabBarFontSize(_ points: Double) async -> Bool

/// Formats a point size for display next to a font-size slider
/// (e.g. `12`, `13.5`), trimming trailing zeros.
func formattedFontSize(_ points: Double) -> String
}

public extension SettingsHostActions {
func browserHistoryEntryCount() -> Int? { nil }

func sidebarFontSize() -> SettingsFontSize {
SettingsFontSize(points: 12.5, minimum: 10, maximum: 20, defaultValue: 12.5)
}

func setSidebarFontSize(_ points: Double) async -> Bool { true }

func surfaceTabBarFontSize() -> SettingsFontSize {
SettingsFontSize(points: 11, minimum: 8, maximum: 24, defaultValue: 11)
}

func setSurfaceTabBarFontSize(_ points: Double) async -> Bool { true }

func formattedFontSize(_ points: Double) -> String {
let scaled = (points * 100).rounded()
let whole = Int(scaled / 100)
let fraction = abs(Int(scaled) % 100)
if fraction == 0 { return "\(whole)" }
if fraction % 10 == 0 { return "\(whole).\(fraction / 10)" }
return "\(whole).\(fraction < 10 ? "0" : "")\(fraction)"
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

/// No-op ``SettingsHostActions`` for previews, tests, and any context
Expand Down
Loading
Loading