diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92ece625de..3251d84889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" \ diff --git a/CLI/CMUXCLI+Config.swift b/CLI/CMUXCLI+Config.swift index 3c9e79bf6f..0a1a0a971d 100644 --- a/CLI/CMUXCLI+Config.swift +++ b/CLI/CMUXCLI+Config.swift @@ -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 ") + } + 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 ") + } + 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") @@ -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 + } return hasHelpRequest(beforeSeparator: parsedArgs.head) || ["help", "path", "paths", "docs", "documentation", "doctor", "check", "validate"].contains(subcommand) } func configUsage() -> String { return """ - Usage: cmux config + Usage: cmux config - 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 ] 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 Print sidebar-font-size or surface-tab-bar-font-size. + set 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) @@ -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 """ } @@ -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 { + if let existing = CmuxGhosttyConfigPathResolver.loadConfigURLs( + currentBundleIdentifier: bundleIdentifier, + appSupportDirectory: appSupportDirectory, + fileManager: fileManager + ).first { + return existing + } + } + return CmuxGhosttyConfigPathResolver.activeOrEditableConfigURL( + currentBundleIdentifier: bundleIdentifier, + appSupportDirectory: firstAppSupportDirectory, + fileManager: fileManager + ) + } + + 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] } diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsFontSize.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsFontSize.swift new file mode 100644 index 0000000000..0bceb174c7 --- /dev/null +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsFontSize.swift @@ -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 + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 3b534ada35..53d6912fa1 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -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: 14, 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)" + } } /// No-op ``SettingsHostActions`` for previews, tests, and any context diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index 2bbaf27fe3..68fb5bea66 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -455,7 +455,7 @@ public struct SettingsWindowRoot: View { TextBoxSection(defaultsStore: defaultsStore, catalog: catalog) .id(anchorID(for: .textBox)) - SidebarSection(defaultsStore: defaultsStore, catalog: catalog) + SidebarSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) .id(anchorID(for: .sidebarAppearance)) BetaFeaturesSection(defaultsStore: defaultsStore, catalog: catalog) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index a6aae9ee60..2dde705522 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -9,7 +9,11 @@ import SwiftUI @MainActor public struct SidebarSection: View { private let catalog: SettingCatalog + private let hostActions: SettingsHostActions + @State private var sidebarFont: SettingsFontSize + @State private var fontSaveFailed = false + @State private var fontSaveTask: Task? @State private var matchTerminal: DefaultsValueModel @State private var hideAll: DefaultsValueModel @State private var wrapTitles: DefaultsValueModel @@ -30,8 +34,10 @@ public struct SidebarSection: View { @State private var showProgress: DefaultsValueModel @State private var showMetadata: DefaultsValueModel - public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog) { + public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog, hostActions: SettingsHostActions) { self.catalog = catalog + self.hostActions = hostActions + _sidebarFont = State(initialValue: hostActions.sidebarFontSize()) _matchTerminal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.matchTerminalBackground)) _hideAll = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.hideAllDetails)) _wrapTitles = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.wrapWorkspaceTitles)) @@ -60,6 +66,17 @@ public struct SidebarSection: View { } } + /// Persists a new sidebar font size, cancelling any in-flight save so a + /// rapid sequence of slider releases only reflects the latest value (the + /// host serializes the underlying writes; this keeps the UI state in step). + private func saveSidebarFontSize(_ points: Double) { + fontSaveTask?.cancel() + fontSaveTask = Task { + let saved = await hostActions.setSidebarFontSize(points) + if !Task.isCancelled { fontSaveFailed = !saved } + } + } + @ViewBuilder private var mainCard: some View { SettingsCard { @@ -75,6 +92,49 @@ public struct SidebarSection: View { } SettingsCardDivider() + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.sidebarAppearance.fontSize", defaultValue: "Sidebar Font Size"), + subtitle: String(localized: "settings.sidebarAppearance.fontSize.subtitle", defaultValue: "Controls workspace titles, metadata, badges, and shortcut hints in the left sidebar."), + controlWidth: 250 + ) { + VStack(alignment: .trailing, spacing: 4) { + HStack(spacing: 8) { + Slider( + value: Binding(get: { sidebarFont.points }, set: { sidebarFont.points = $0 }), + in: sidebarFont.minimum...sidebarFont.maximum, + step: 0.5 + ) { editing in + if !editing { saveSidebarFontSize(sidebarFont.points) } + } + .frame(width: 130) + .accessibilityIdentifier("SettingsSidebarFontSizeSlider") + + Text(String.localizedStringWithFormat(String(localized: "settings.fontSize.valuePoints", defaultValue: "%@ pt"), hostActions.formattedFontSize(sidebarFont.points))) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + + Button(String(localized: "settings.sidebarAppearance.fontSize.reset", defaultValue: "Reset")) { + sidebarFont.points = sidebarFont.defaultValue + saveSidebarFontSize(sidebarFont.points) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(sidebarFont.isDefault) + } + + if fontSaveFailed { + Text(String(localized: "settings.sidebarAppearance.fontSize.saveFailed", defaultValue: "Couldn't save sidebar font size. Please try again.")) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } + } + } + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("sidebar.hideAllDetails"), String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift index 59ab2a8958..292848ae72 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift @@ -11,6 +11,9 @@ public struct TerminalSection: View { private let catalog: SettingCatalog private let hostActions: SettingsHostActions + @State private var surfaceTabBarFont: SettingsFontSize + @State private var fontSaveFailed = false + @State private var fontSaveTask: Task? @State private var scrollBar: DefaultsValueModel @State private var copyOnSelect: DefaultsValueModel @State private var autoResume: DefaultsValueModel @@ -27,6 +30,7 @@ public struct TerminalSection: View { self.jsonStore = jsonStore self.catalog = catalog self.hostActions = hostActions + _surfaceTabBarFont = State(initialValue: hostActions.surfaceTabBarFontSize()) _scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar)) _copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect)) _autoResume = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.autoResumeAgentSessions)) @@ -43,6 +47,17 @@ public struct TerminalSection: View { } } + /// Persists a new tab-bar font size, cancelling any in-flight save so a + /// rapid sequence of slider releases only reflects the latest value (the + /// host serializes the underlying writes; this keeps the UI state in step). + private func saveSurfaceTabBarFontSize(_ points: Double) { + fontSaveTask?.cancel() + fontSaveTask = Task { + let saved = await hostActions.setSurfaceTabBarFontSize(points) + if !Task.isCancelled { fontSaveFailed = !saved } + } + } + @ViewBuilder private var resumeCommandsCard: some View { SettingsCard { @@ -72,6 +87,48 @@ public struct TerminalSection: View { @ViewBuilder private var mainCard: some View { SettingsCard { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), + subtitle: String(localized: "settings.terminal.tabBarFontSize.subtitle", defaultValue: "Controls the font size of the terminal and browser tab titles at the top of each pane."), + controlWidth: 250 + ) { + VStack(alignment: .trailing, spacing: 4) { + HStack(spacing: 8) { + Slider( + value: Binding(get: { surfaceTabBarFont.points }, set: { surfaceTabBarFont.points = $0 }), + in: surfaceTabBarFont.minimum...surfaceTabBarFont.maximum, + step: 0.5 + ) { editing in + if !editing { saveSurfaceTabBarFontSize(surfaceTabBarFont.points) } + } + .frame(width: 130) + .accessibilityIdentifier("SettingsTabBarFontSizeSlider") + + Text(String.localizedStringWithFormat(String(localized: "settings.fontSize.valuePoints", defaultValue: "%@ pt"), hostActions.formattedFontSize(surfaceTabBarFont.points))) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + + Button(String(localized: "settings.terminal.tabBarFontSize.reset", defaultValue: "Reset")) { + surfaceTabBarFont.points = surfaceTabBarFont.defaultValue + saveSurfaceTabBarFontSize(surfaceTabBarFont.points) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(surfaceTabBarFont.isDefault) + } + + if fontSaveFailed { + Text(String(localized: "settings.terminal.tabBarFontSize.saveFailed", defaultValue: "Couldn't save tab bar font size. Please try again.")) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } + } + } + SettingsCardDivider() SettingsCardRow( configurationReview: .json("terminal.showScrollBar"), String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 779e072f37..be65bc5c9d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -104892,6 +104892,23 @@ } } }, + "settings.fontSize.valuePoints": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + } + } + }, "settings.globalHotkey.enable": { "extractionState": "manual", "localizations": { @@ -112134,6 +112151,23 @@ } } }, + "settings.search.alias.setting.terminal.tab-bar-font-size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface-tab-bar-font-size tab bar font size text scale terminal browser pane tab title" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface-tab-bar-font-size tab bar font size text scale terminal browser pane tab title タブバー フォントサイズ テキスト 拡大 ターミナル ブラウザ ペーン タブ タイトル" + } + } + } + }, "settings.search.alias.setting.terminal.copy-on-select": { "extractionState": "manual", "localizations": { @@ -112259,6 +112293,23 @@ } } }, + "settings.search.alias.setting.sidebarAppearance.font-size": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "sidebar-font-size sidebar font size text scale workspace title badge metadata shortcut hint" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "sidebar-font-size sidebar font size text scale workspace title badge metadata shortcut hint サイドバー フォントサイズ テキスト 拡大 ワークスペース タイトル バッジ メタデータ ショートカット ヒント" + } + } + } + }, "settings.search.alias.setting.sidebarAppearance.match-terminal": { "extractionState": "manual", "localizations": { @@ -138608,6 +138659,91 @@ } } }, + "settings.sidebarAppearance.fontSize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar Font Size" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのフォントサイズ" + } + } + } + }, + "settings.sidebarAppearance.fontSize.points": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + } + } + }, + "settings.sidebarAppearance.fontSize.reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } + } + }, + "settings.sidebarAppearance.fontSize.saveFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't save sidebar font size. Please try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのフォントサイズを保存できませんでした。もう一度お試しください。" + } + } + } + }, + "settings.sidebarAppearance.fontSize.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls workspace titles, metadata, badges, and shortcut hints in the left sidebar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左サイドバーのワークスペースタイトル、メタデータ、バッジ、ショートカットヒントのサイズを調整します。" + } + } + } + }, "settings.sidebarAppearance.matchTerminalBackground": { "extractionState": "manual", "localizations": { @@ -142563,6 +142699,91 @@ } } }, + "settings.terminal.tabBarFontSize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab Bar Font Size" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブバーのフォントサイズ" + } + } + } + }, + "settings.terminal.tabBarFontSize.points": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ pt" + } + } + } + }, + "settings.terminal.tabBarFontSize.reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + } + } + }, + "settings.terminal.tabBarFontSize.saveFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't save tab bar font size. Please try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブバーのフォントサイズを保存できませんでした。もう一度お試しください。" + } + } + } + }, + "settings.terminal.tabBarFontSize.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls the font size of the terminal and browser tab titles at the top of each pane." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各ペーン上部のターミナルとブラウザのタブタイトルのフォントサイズを調整します。" + } + } + } + }, "settings.terminal.scrollBar": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxApplicationSupportDirectories.swift b/Sources/CmuxApplicationSupportDirectories.swift index fd3f9d6690..59ede1e88a 100644 --- a/Sources/CmuxApplicationSupportDirectories.swift +++ b/Sources/CmuxApplicationSupportDirectories.swift @@ -175,3 +175,156 @@ enum CmuxGhosttyConfigPathResolver { || bundleIdentifier.hasPrefix("\(channelBundleIdentifier).") } } + +enum CmuxGhosttyConfigSettingEditor { + static let sidebarFontSizeKey = "sidebar-font-size" + static let defaultSidebarFontSize = 12.5 + static let minSidebarFontSize = 10.0 + static let maxSidebarFontSize = 20.0 + + static let surfaceTabBarFontSizeKey = "surface-tab-bar-font-size" + static let defaultSurfaceTabBarFontSize = 11.0 + static let minSurfaceTabBarFontSize = 8.0 + static let maxSurfaceTabBarFontSize = 14.0 + + static func clampedSidebarFontSize(_ value: Double) -> Double { + guard value.isFinite else { return defaultSidebarFontSize } + return min(max(value, minSidebarFontSize), maxSidebarFontSize) + } + + static func formattedSidebarFontSize(_ value: Double) -> String { + formattedFontSize(clampedSidebarFontSize(value)) + } + + static func parsedSidebarFontSize(in contents: String) -> Double? { + parsedFontSize(in: contents, key: sidebarFontSizeKey, clamp: clampedSidebarFontSize) + } + + static func clampedSurfaceTabBarFontSize(_ value: Double) -> Double { + guard value.isFinite else { return defaultSurfaceTabBarFontSize } + return min(max(value, minSurfaceTabBarFontSize), maxSurfaceTabBarFontSize) + } + + static func formattedSurfaceTabBarFontSize(_ value: Double) -> String { + formattedFontSize(clampedSurfaceTabBarFontSize(value)) + } + + static func parsedSurfaceTabBarFontSize(in contents: String) -> Double? { + parsedFontSize(in: contents, key: surfaceTabBarFontSizeKey, clamp: clampedSurfaceTabBarFontSize) + } + + /// Formats a point size for display, trimming trailing zeros (`12`, `13.5`, `13.75`). + static func formattedFontSize(_ value: Double) -> String { + let scaled = Int((value * 100).rounded()) + let whole = scaled / 100 + let fraction = abs(scaled % 100) + if fraction == 0 { + return "\(whole)" + } + if fraction % 10 == 0 { + return "\(whole).\(fraction / 10)" + } + return "\(whole).\(fraction < 10 ? "0" : "")\(fraction)" + } + + /// Reads the last occurrence of `key` from a Ghostty config body and clamps it to the setting's range. + private static func parsedFontSize( + in contents: String, + key: String, + clamp: (Double) -> Double + ) -> Double? { + guard let rawValue = parsedValue(for: key, in: contents) else { + return nil + } + let unquoted = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + guard let value = Double(unquoted), value.isFinite else { + return nil + } + return clamp(value) + } + + static func parsedValue(for key: String, in contents: String) -> String? { + var latestValue: String? + for line in contents.components(separatedBy: .newlines) { + guard let setting = parsedSetting(in: line), setting.key == key else { + continue + } + latestValue = setting.value + } + return latestValue + } + + static func updatedContents(_ contents: String, setting key: String, value: String) -> String { + var lines = contents.components(separatedBy: "\n") + if contents.hasSuffix("\n") { + lines.removeLast() + } + if lines.count == 1, lines[0].isEmpty { + lines = [] + } + + var didReplace = false + for index in lines.indices { + guard parsedSetting(in: lines[index])?.key == key else { + continue + } + lines[index] = "\(key) = \(value)" + didReplace = true + } + + if !didReplace { + lines.append("\(key) = \(value)") + } + return lines.joined(separator: "\n") + "\n" + } + + static func writeSetting( + key: String, + value: String, + to url: URL, + fileManager: FileManager = .default + ) throws { + let writeURL = configWriteURL(for: url, fileManager: fileManager) + let contents = (try? String(contentsOf: writeURL, encoding: .utf8)) + ?? (try? String(contentsOf: url, encoding: .utf8)) + ?? "" + let updated = updatedContents(contents, setting: key, value: value) + try fileManager.createDirectory( + at: writeURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try updated.write(to: writeURL, atomically: true, encoding: .utf8) + } + + private static func parsedSetting(in line: String) -> (key: String, value: String)? { + var trimmed = line.trimmingCharacters(in: .whitespaces) + // Strip a leading UTF-8 BOM so a BOM-encoded first line still matches its + // key (otherwise the setting reads as absent and a duplicate is appended). + if trimmed.hasPrefix("\u{FEFF}") { + trimmed.removeFirst() + trimmed = trimmed.trimmingCharacters(in: .whitespaces) + } + guard !trimmed.isEmpty, !trimmed.hasPrefix("#"), let separator = trimmed.firstIndex(of: "=") else { + return nil + } + let key = trimmed[.. URL { + guard let destination = try? fileManager.destinationOfSymbolicLink(atPath: url.path) else { + return url + } + let destinationURL: URL + if destination.hasPrefix("/") { + destinationURL = URL(fileURLWithPath: destination) + } else { + destinationURL = url.deletingLastPathComponent().appendingPathComponent(destination) + } + return destinationURL.standardizedFileURL.resolvingSymlinksInPath() + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5a5adfc0a4..a6f8169dd1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9821,6 +9821,14 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { } } +private enum SidebarFontSizeProvider { + static func loadFromGhosttyConfig() async -> CGFloat { + await Task.detached(priority: .utility) { + GhosttyConfig.load().sidebarFontSize + }.value + } +} + struct SidebarTabItemSettingsSnapshot: Equatable { let hidesAllDetails: Bool let wrapsWorkspaceTitles: Bool @@ -9828,6 +9836,7 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool + let sidebarFontScale: CGFloat let showsGitBranch: Bool let usesVerticalBranchLayout: Bool let stacksBranchAndDirectory: Bool @@ -9844,10 +9853,14 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility let iMessageModeEnabled: Bool - init(defaults: UserDefaults = .standard) { + init( + defaults: UserDefaults = .standard, + sidebarFontSize: CGFloat = GhosttyConfig.defaultSidebarFontSize + ) { sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY alwaysShowShortcutHints = ShortcutHintDebugSettings.alwaysShowHints() + sidebarFontScale = SidebarTabItemFontScale.scale(for: sidebarFontSize) showsGitBranch = Self.bool(defaults: defaults, key: "sidebarShowGitBranch", defaultValue: true) usesVerticalBranchLayout = SidebarBranchLayoutSettings.usesVerticalLayout(defaults: defaults) stacksBranchAndDirectory = SidebarBranchDirectoryStackedSettings.isStacked(defaults: defaults) @@ -9910,14 +9923,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { return defaults.bool(forKey: key) } - private static func double( - defaults: UserDefaults, - key: String, - defaultValue: Double - ) -> Double { - guard let value = defaults.object(forKey: key) as? NSNumber else { return defaultValue } - return value.doubleValue - } } private extension String { @@ -10026,11 +10031,24 @@ private final class SidebarTabItemSettingsStore: ObservableObject { @Published private(set) var snapshot: SidebarTabItemSettingsSnapshot private let defaults: UserDefaults + private let sidebarFontSizeProvider: () async -> CGFloat + private var sidebarFontSize: CGFloat + private var sidebarFontSizeLoadTask: Task? private var defaultsObserver: NSObjectProtocol? + private var ghosttyConfigObserver: NSObjectProtocol? - init(defaults: UserDefaults = .standard) { + init( + defaults: UserDefaults = .standard, + initialSidebarFontSize: CGFloat = GhosttyConfig.defaultSidebarFontSize, + sidebarFontSizeProvider: @escaping () async -> CGFloat = SidebarFontSizeProvider.loadFromGhosttyConfig + ) { self.defaults = defaults - self.snapshot = SidebarTabItemSettingsSnapshot(defaults: defaults) + self.sidebarFontSize = GhosttyConfig.clampedSidebarFontSize(initialSidebarFontSize) + self.sidebarFontSizeProvider = sidebarFontSizeProvider + self.snapshot = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSize: sidebarFontSize + ) defaultsObserver = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, @@ -10040,19 +10058,47 @@ private final class SidebarTabItemSettingsStore: ObservableObject { self?.refreshSnapshot() } } + refreshSidebarFontSize() + ghosttyConfigObserver = NotificationCenter.default.addObserver( + forName: .ghosttyConfigDidReload, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshSidebarFontSize() + } + } } deinit { + sidebarFontSizeLoadTask?.cancel() if let defaultsObserver { NotificationCenter.default.removeObserver(defaultsObserver) } + if let ghosttyConfigObserver { + NotificationCenter.default.removeObserver(ghosttyConfigObserver) + } } private func refreshSnapshot() { - let nextSnapshot = SidebarTabItemSettingsSnapshot(defaults: defaults) + let nextSnapshot = SidebarTabItemSettingsSnapshot( + defaults: defaults, + sidebarFontSize: sidebarFontSize + ) guard nextSnapshot != snapshot else { return } snapshot = nextSnapshot } + + private func refreshSidebarFontSize() { + sidebarFontSizeLoadTask?.cancel() + sidebarFontSizeLoadTask = Task { @MainActor [weak self] in + guard let self else { return } + let loadedSidebarFontSize = await sidebarFontSizeProvider() + guard !Task.isCancelled else { return } + sidebarFontSize = GhosttyConfig.clampedSidebarFontSize(loadedSidebarFontSize) + refreshSnapshot() + } + } } /// Transient sidebar drag/drop state, owned by `VerticalTabsSidebar` and passed @@ -10221,7 +10267,9 @@ struct VerticalTabsSidebar: View { @State var modifierKeyMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @StateObject var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() - @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore() + @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore( + initialSidebarFontSize: GhosttyConfig.load().sidebarFontSize + ) @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State var dragState = SidebarDragState() // Freezes `showsModifierShortcutHints` for the workspace whose context menu @@ -14647,6 +14695,14 @@ struct TabItemView: View, Equatable { .semibold } + private var fontScale: CGFloat { + settings.sidebarFontScale + } + + private func scaledFontSize(_ baseSize: CGFloat) -> CGFloat { + baseSize * fontScale + } + private var showsLeadingRail: Bool { explicitRailColor != nil } @@ -14795,7 +14851,7 @@ struct TabItemView: View, Equatable { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: scaledFontSize(10), design: .monospaced)) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.middle) @@ -14803,7 +14859,7 @@ struct TabItemView: View, Equatable { Spacer(minLength: 0) Text(workspaceSnapshot.remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) + .font(.system(size: scaledFontSize(9), weight: .medium)) .foregroundColor(activeSecondaryColor(0.58)) .lineLimit(1) } @@ -14858,6 +14914,12 @@ struct TabItemView: View, Equatable { : nil let effectiveSubtitle = latestNotificationSubtitle ?? conversationMessageSubtitle let detailVisibility = visibleAuxiliaryDetails + let scaledUnreadBadgeSize = 16 * fontScale + let scaledCloseButtonHitSize = max(16, 16 * fontScale) + let scaledCloseButtonWidth = max( + SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, + scaledCloseButtonHitSize + ) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top, spacing: 8) { @@ -14866,21 +14928,21 @@ struct TabItemView: View, Equatable { Circle() .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: scaledFontSize(9), weight: .semibold)) .foregroundColor(activeUnreadBadgeTextColor) } - .frame(width: 16, height: 16) + .frame(width: scaledUnreadBadgeSize, height: scaledUnreadBadgeSize) } if workspaceSnapshot.isPinned { Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: scaledFontSize(9), weight: .semibold)) .foregroundColor(activeSecondaryColor(0.8)) .safeHelp(protectedWorkspaceTooltip) } Text(workspaceSnapshot.title) - .font(.system(size: 12.5, weight: titleFontWeight)) + .font(.system(size: scaledFontSize(12.5), weight: titleFontWeight)) .foregroundColor(activePrimaryTextColor) .lineLimit(settings.wrapsWorkspaceTitles ? nil : 1) .truncationMode(.tail) @@ -14893,14 +14955,15 @@ struct TabItemView: View, Equatable { SidebarWorkspaceDescriptionText( markdown: description, isActive: usesInvertedActiveForeground, - activeForegroundColor: activeSecondaryColor(0.84) + activeForegroundColor: activeSecondaryColor(0.84), + fontScale: fontScale ) .id(description) } if let subtitle = effectiveSubtitle { Text(subtitle) - .font(.system(size: 10)) + .font(.system(size: scaledFontSize(10))) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(2) .truncationMode(.tail) @@ -14918,6 +14981,7 @@ struct TabItemView: View, Equatable { isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.95), activeSecondaryForegroundColor: activeSecondaryColor(0.65), + fontScale: fontScale, onFocus: { updateSelection() } ) .transition(.opacity.combined(with: .move(edge: .top))) @@ -14928,6 +14992,7 @@ struct TabItemView: View, Equatable { isActive: usesInvertedActiveForeground, activeForegroundColor: activeSecondaryColor(0.8), activeSecondaryForegroundColor: activeSecondaryColor(0.65), + fontScale: fontScale, onFocus: { updateSelection() } ) .transition(.opacity.combined(with: .move(edge: .top))) @@ -14937,10 +15002,10 @@ struct TabItemView: View, Equatable { if detailVisibility.showsLog, let latestLog = workspaceSnapshot.latestLog { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) + .font(.system(size: scaledFontSize(8))) .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) - .font(.system(size: 10)) + .font(.system(size: scaledFontSize(10))) .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.tail) @@ -14959,11 +15024,11 @@ struct TabItemView: View, Equatable { .frame(width: max(0, geo.size.width * CGFloat(progress.value))) } } - .frame(height: 3) + .frame(height: max(3, 3 * fontScale)) if let label = progress.label { Text(label) - .font(.system(size: 9)) + .font(.system(size: scaledFontSize(9))) .foregroundColor(activeSecondaryColor(0.6)) .lineLimit(1) } @@ -14978,7 +15043,7 @@ struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.branchLinesContainBranch { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: scaledFontSize(9))) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { @@ -14986,7 +15051,7 @@ struct TabItemView: View, Equatable { if sidebarStacksBranchAndDirectory { if let branch = line.branch { Text(branch) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: scaledFontSize(10), design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -14994,28 +15059,30 @@ struct TabItemView: View, Equatable { if !line.directoryCandidates.isEmpty { SidebarDirectoryText( candidates: line.directoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } else { HStack(spacing: 3) { if let branch = line.branch { Text(branch) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: scaledFontSize(10), design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } if line.branch != nil, !line.directoryCandidates.isEmpty { Image(systemName: "circle.fill") - .font(.system(size: 3)) + .font(.system(size: scaledFontSize(3))) .foregroundColor(activeSecondaryColor(0.6)) .padding(.horizontal, 1) } if !line.directoryCandidates.isEmpty { SidebarDirectoryText( candidates: line.directoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } @@ -15030,13 +15097,13 @@ struct TabItemView: View, Equatable { HStack(alignment: .top, spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: scaledFontSize(9))) .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { if let branchRow = workspaceSnapshot.compactGitBranchSummaryText { Text(branchRow) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: scaledFontSize(10), design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) @@ -15044,7 +15111,8 @@ struct TabItemView: View, Equatable { if !workspaceSnapshot.compactDirectoryCandidates.isEmpty { SidebarDirectoryText( candidates: workspaceSnapshot.compactDirectoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } @@ -15053,12 +15121,13 @@ struct TabItemView: View, Equatable { HStack(spacing: 3) { if sidebarShowGitBranchIcon, workspaceSnapshot.compactGitBranchSummaryText != nil { Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) + .font(.system(size: scaledFontSize(9))) .foregroundColor(activeSecondaryColor(0.6)) } SidebarDirectoryText( candidates: workspaceSnapshot.compactBranchDirectoryCandidates, - color: activeSecondaryColor(0.75) + color: activeSecondaryColor(0.75), + fontScale: fontScale ) } } @@ -15071,12 +15140,16 @@ struct TabItemView: View, Equatable { let pullRequestNumber = String(pullRequest.number) let pullRequestTitle = "\(pullRequest.label) #\(pullRequestNumber)" let rowContent = HStack(spacing: 4) { - PullRequestStatusIcon(status: pullRequest.status, color: pullRequestForegroundColor) + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor, + fontScale: fontScale + ) Text(pullRequestTitle).underline(settings.makesPullRequestsClickable).lineLimit(1).truncationMode(.tail) Text(pullRequestStatusLabel(pullRequest.status)).lineLimit(1) Spacer(minLength: 0) } - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: scaledFontSize(10), weight: .semibold)) .foregroundColor(pullRequestForegroundColor) .opacity(pullRequest.isStale ? 0.5 : 1) if settings.makesPullRequestsClickable { @@ -15109,7 +15182,7 @@ struct TabItemView: View, Equatable { } Spacer(minLength: 0) } - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: scaledFontSize(10), design: .monospaced)) .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) } @@ -15141,7 +15214,8 @@ struct TabItemView: View, Equatable { text: showsWorkspaceShortcutHint ? workspaceShortcutLabel : nil, emphasis: shortcutHintEmphasis, offsetX: sidebarShortcutHintXOffset, - offsetY: sidebarShortcutHintYOffset + offsetY: sidebarShortcutHintYOffset, + fontSize: scaledFontSize(10) ) .overlay(alignment: .topTrailing) { if showsWorkspaceShortcutHint { @@ -15154,12 +15228,12 @@ struct TabItemView: View, Equatable { tabManager.closeWorkspaceWithConfirmation(tab) }) { Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) + .font(.system(size: scaledFontSize(9), weight: .medium)) .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .frame(width: scaledCloseButtonWidth, height: scaledCloseButtonHitSize, alignment: .center) .padding(.top, 8) .padding(.trailing, 10) } @@ -16162,19 +16236,33 @@ struct TabItemView: View, Equatable { private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color - private static let frameSize: CGFloat = 12 + var fontScale: CGFloat = 1 + private static let closedFrameSize: CGFloat = 12 + private static let customFrameSize: CGFloat = 13 + + private var closedFrameSize: CGFloat { + Self.closedFrameSize * fontScale + } + + private var customFrameSize: CGFloat { + Self.customFrameSize * fontScale + } var body: some View { switch status { case .open: PullRequestOpenIcon(color: color) + .scaleEffect(fontScale) + .frame(width: customFrameSize, height: customFrameSize) case .merged: PullRequestMergedIcon(color: color) + .scaleEffect(fontScale) + .frame(width: customFrameSize, height: customFrameSize) case .closed: Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) + .font(.system(size: 7 * fontScale, weight: .regular)) .foregroundColor(color) - .frame(width: Self.frameSize, height: Self.frameSize) + .frame(width: closedFrameSize, height: closedFrameSize) } } } @@ -16352,6 +16440,7 @@ private struct SidebarWorkspaceDescriptionText: View { let markdown: String let isActive: Bool let activeForegroundColor: Color + let fontScale: CGFloat var body: some View { let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) @@ -16362,7 +16451,7 @@ private struct SidebarWorkspaceDescriptionText: View { Text(markdown) } } - .font(.system(size: 10.5)) + .font(.system(size: 10.5 * fontScale)) .foregroundColor(foregroundColor) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) @@ -16423,6 +16512,7 @@ private struct SidebarMetadataRows: View { let isActive: Bool let activeForegroundColor: Color let activeSecondaryForegroundColor: Color + let fontScale: CGFloat let onFocus: () -> Void @State private var isExpanded: Bool = false @@ -16435,6 +16525,7 @@ private struct SidebarMetadataRows: View { entry: entry, isActive: isActive, activeForegroundColor: activeForegroundColor, + fontScale: fontScale, onFocus: onFocus ) } @@ -16447,7 +16538,7 @@ private struct SidebarMetadataRows: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -16477,6 +16568,7 @@ private struct SidebarMetadataEntryRow: View { let entry: SidebarStatusEntry let isActive: Bool let activeForegroundColor: Color + let fontScale: CGFloat let onFocus: () -> Void var body: some View { @@ -16510,7 +16602,7 @@ private struct SidebarMetadataEntryRow: View { .truncationMode(.tail) Spacer(minLength: 0) } - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -16534,12 +16626,12 @@ private struct SidebarMetadataEntryRow: View { if iconRaw.hasPrefix("emoji:") { let value = String(iconRaw.dropFirst("emoji:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9))) + return AnyView(Text(value).font(.system(size: 9 * fontScale))) } if iconRaw.hasPrefix("text:") { let value = String(iconRaw.dropFirst("text:".count)) guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + return AnyView(Text(value).font(.system(size: 8 * fontScale, weight: .semibold))) } let symbolName: String if iconRaw.hasPrefix("sf:") { @@ -16548,7 +16640,7 @@ private struct SidebarMetadataEntryRow: View { symbolName = iconRaw } guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + return AnyView(Image(systemName: symbolName).font(.system(size: 8 * fontScale, weight: .medium))) } @ViewBuilder @@ -16576,6 +16668,7 @@ private struct SidebarMetadataMarkdownBlocks: View { let isActive: Bool let activeForegroundColor: Color let activeSecondaryForegroundColor: Color + let fontScale: CGFloat let onFocus: () -> Void @State private var isExpanded: Bool = false @@ -16588,6 +16681,7 @@ private struct SidebarMetadataMarkdownBlocks: View { block: block, isActive: isActive, activeForegroundColor: activeForegroundColor, + fontScale: fontScale, onFocus: onFocus ) } @@ -16600,7 +16694,7 @@ private struct SidebarMetadataMarkdownBlocks: View { } } .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 10 * fontScale, weight: .semibold)) .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } @@ -16621,6 +16715,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { let block: SidebarMetadataBlock let isActive: Bool let activeForegroundColor: Color + let fontScale: CGFloat let onFocus: () -> Void @State private var renderedMarkdown: AttributedString? @@ -16635,7 +16730,7 @@ private struct SidebarMetadataMarkdownBlockRow: View { .foregroundColor(foregroundColor) } } - .font(.system(size: 10)) + .font(.system(size: 10 * fontScale)) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 667ac64fb6..ec8ca23c0c 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -13,10 +13,17 @@ struct GhosttyConfig { private static let loadCacheLock = NSLock() private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:] + static let defaultSidebarFontSize = CGFloat(CmuxGhosttyConfigSettingEditor.defaultSidebarFontSize) + static let minSidebarFontSize = CGFloat(CmuxGhosttyConfigSettingEditor.minSidebarFontSize) + static let maxSidebarFontSize = CGFloat(CmuxGhosttyConfigSettingEditor.maxSidebarFontSize) + static let defaultSurfaceTabBarFontSize = CGFloat(CmuxGhosttyConfigSettingEditor.defaultSurfaceTabBarFontSize) + static let minSurfaceTabBarFontSize = CGFloat(CmuxGhosttyConfigSettingEditor.minSurfaceTabBarFontSize) + static let maxSurfaceTabBarFontSize = CGFloat(CmuxGhosttyConfigSettingEditor.maxSurfaceTabBarFontSize) var fontFamily: String = "Menlo" var fontSize: CGFloat = 12 - var surfaceTabBarFontSize: CGFloat = 11 + var surfaceTabBarFontSize: CGFloat = Self.defaultSurfaceTabBarFontSize + var sidebarFontSize: CGFloat = Self.defaultSidebarFontSize var theme: String? var workingDirectory: String? // Ghostty measures scrollback-limit in bytes, not lines. @@ -373,7 +380,14 @@ struct GhosttyConfig { ) { let lines = contents.components(separatedBy: .newlines) for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) + var trimmed = line.trimmingCharacters(in: .whitespaces) + // Strip a leading UTF-8 BOM so a BOM-encoded first line (e.g. a + // `sidebar-font-size` setting) is still parsed instead of silently + // ignored, matching `CmuxGhosttyConfigSettingEditor.parsedSetting`. + if trimmed.hasPrefix("\u{FEFF}") { + trimmed.removeFirst() + trimmed = trimmed.trimmingCharacters(in: .whitespaces) + } if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } @@ -391,8 +405,12 @@ struct GhosttyConfig { fontSize = CGFloat(size) } case "surface-tab-bar-font-size": - if let size = Double(value) { - surfaceTabBarFontSize = CGFloat(size) + if let size = Double(value), size.isFinite { + surfaceTabBarFontSize = Self.clampedSurfaceTabBarFontSize(CGFloat(size)) + } + case "sidebar-font-size": + if let size = Double(value), size.isFinite { + sidebarFontSize = Self.clampedSidebarFontSize(CGFloat(size)) } case "theme": theme = value @@ -655,6 +673,14 @@ struct GhosttyConfig { return parsed } + static func clampedSidebarFontSize(_ value: CGFloat) -> CGFloat { + CGFloat(CmuxGhosttyConfigSettingEditor.clampedSidebarFontSize(Double(value))) + } + + static func clampedSurfaceTabBarFontSize(_ value: CGFloat) -> CGFloat { + CGFloat(CmuxGhosttyConfigSettingEditor.clampedSurfaceTabBarFontSize(Double(value))) + } + private static func parseBackgroundBlur(_ value: String) -> GhosttyBackgroundBlur? { switch value { case "false", "0": diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 78840509db..f9210bd8cb 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -1,8 +1,11 @@ import AppKit import CmuxSettingsUI import Foundation +import OSLog import SwiftUI +private let hostSettingsLogger = Logger(subsystem: "com.cmuxterm.app", category: "Settings") + /// App-side implementation of the package's `SettingsHostActions` /// protocol. Routes UI-triggered actions to the existing host /// services (`BrowserHistoryStore`, `BrowserDataImportCoordinator`, @@ -12,6 +15,9 @@ import SwiftUI final class HostSettingsActions: SettingsHostActions { private let configFileURL: URL + /// Serializes font-size config writes so rapid slider saves persist in order. + private let fontConfigWriter = FontConfigWriter() + /// AppKit window identifier the dedicated terminal-config window carries. /// Matches the value `ConfigSettingsView.configureWindow` assigns so the /// host reuses a config window opened from any entrypoint (the legacy @@ -146,6 +152,91 @@ final class HostSettingsActions: SettingsHostActions { guard BrowserHistoryStore.shared.isLoaded else { return nil } return BrowserHistoryStore.shared.entries.count } + + func sidebarFontSize() -> SettingsFontSize { + // Reads the in-memory cache (kept current by config reloads) rather than + // forcing a synchronous disk read on the main actor when Settings opens. + SettingsFontSize( + points: Double(GhosttyConfig.load().sidebarFontSize), + minimum: CmuxGhosttyConfigSettingEditor.minSidebarFontSize, + maximum: CmuxGhosttyConfigSettingEditor.maxSidebarFontSize, + defaultValue: CmuxGhosttyConfigSettingEditor.defaultSidebarFontSize + ) + } + + func setSidebarFontSize(_ points: Double) async -> Bool { + await persistFontSize( + key: CmuxGhosttyConfigSettingEditor.sidebarFontSizeKey, + points: CmuxGhosttyConfigSettingEditor.clampedSidebarFontSize(points), + reloadSource: "settings.sidebar.fontSize" + ) + } + + func surfaceTabBarFontSize() -> SettingsFontSize { + // See ``sidebarFontSize()`` — uses the cached config to avoid main-actor disk I/O. + SettingsFontSize( + points: Double(GhosttyConfig.load().surfaceTabBarFontSize), + minimum: CmuxGhosttyConfigSettingEditor.minSurfaceTabBarFontSize, + maximum: CmuxGhosttyConfigSettingEditor.maxSurfaceTabBarFontSize, + defaultValue: CmuxGhosttyConfigSettingEditor.defaultSurfaceTabBarFontSize + ) + } + + func setSurfaceTabBarFontSize(_ points: Double) async -> Bool { + await persistFontSize( + key: CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey, + points: CmuxGhosttyConfigSettingEditor.clampedSurfaceTabBarFontSize(points), + reloadSource: "settings.terminal.tabBarFontSize" + ) + } + + func formattedFontSize(_ points: Double) -> String { + CmuxGhosttyConfigSettingEditor.formattedFontSize(points) + } + + /// Writes a clamped font-size value to cmux's editable Ghostty config and + /// triggers a live reload so open windows re-render at the new size. + /// + /// The disk write runs on the serial ``fontConfigWriter`` actor so the main + /// actor is never blocked on file I/O during a slider drag or Reset tap, and + /// rapid successive saves persist in submission order (last value wins). The + /// reload then resumes on the main actor. + /// + /// - Returns: `true` on success, `false` if the write failed (a generic + /// warning is logged here; the Settings UI surfaces a save-failed message). + private func persistFontSize(key: String, points: Double, reloadSource: String) async -> Bool { + let formatted = CmuxGhosttyConfigSettingEditor.formattedFontSize(points) + guard await fontConfigWriter.write(key: key, value: formatted) else { + hostSettingsLogger.warning("failed to persist \(key, privacy: .public)") + return false + } + GhosttyApp.shared.reloadConfiguration(source: reloadSource) + return true + } +} + +/// Serializes cmux Ghostty config writes for the font-size settings so rapid +/// successive saves apply in submission order instead of racing. +/// +/// The Settings sliders fire a save on every release and Reset tap. Routed +/// through this single actor, the writes run one-at-a-time in arrival order — +/// each write is a full overwrite of the key, so the most recently submitted +/// value is always the one left on disk. The work runs off the main actor. +private actor FontConfigWriter { + /// Writes a single cmux-editable Ghostty config setting to disk. + /// + /// - Parameters: + /// - key: The Ghostty config key to write (e.g. `sidebar-font-size`). + /// - value: The already-formatted value to persist. + /// - Returns: `true` if the write succeeded, `false` otherwise. + func write(key: String, value: String) -> Bool { + do { + try ConfigSourceEnvironment.live().writeCmuxConfigSetting(key: key, value: value) + return true + } catch { + return false + } + } } private extension UserDefaults { diff --git a/Sources/Settings/ConfigSource.swift b/Sources/Settings/ConfigSource.swift index 438171fd2a..d8bf9e4299 100644 --- a/Sources/Settings/ConfigSource.swift +++ b/Sources/Settings/ConfigSource.swift @@ -103,6 +103,16 @@ struct ConfigSourceEnvironment { try writeCmuxConfigContents(contents, to: url) } + func writeCmuxConfigSetting(key: String, value: String) throws { + let url = try materializeCmuxConfigFileIfNeeded() + try CmuxGhosttyConfigSettingEditor.writeSetting( + key: key, + value: value, + to: url, + fileManager: fileManager + ) + } + private func writeCmuxConfigContents(_ contents: String, to url: URL) throws { let writeURL = configWriteURL(for: url) try fileManager.createDirectory( diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 81323b2989..f8526f817d 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -341,6 +341,7 @@ enum SettingsSearchIndex { setting(.app, "palette-search-all", String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), "cmd p search terminal browser markdown"), setting(.terminal, "scrollbar", String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), "terminal shell scrollback"), setting(.terminal, "copy-on-select", String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), "terminal.copyOnSelect clipboard selection mouse double click triple click"), + setting(.terminal, "tab-bar-font-size", String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), "font size text scale terminal browser pane tab title surface-tab-bar-font-size"), setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"), setting(.terminal, "agent-hibernation", String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"), setting(.terminal, "resume-commands", String(localized: "settings.terminal.resumeCommands", defaultValue: "Resume Commands"), "surface resume command approvals prefixes auto restore prompt manual tmux hibernation"), @@ -348,6 +349,7 @@ enum SettingsSearchIndex { setting(.textBox, "focus-textbox-new-terminals", String(localized: "settings.textBox.focusOnNewTerminals", defaultValue: "Focus TextBox on New Terminals"), "terminal.focusTextBoxOnNewTerminals textbox text box rich input prompt default new workspace split tab beta"), setting(.textBox, "textbox-max-lines", String(localized: "settings.textBox.maxLines", defaultValue: "TextBox Max Lines"), "terminal.textBoxMaxLines terminal textbox text box rich input prompt max height lines grow scroll beta"), setting(.sidebarAppearance, "match-terminal", String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), "sidebar material transparency"), + setting(.sidebarAppearance, "font-size", String(localized: "settings.sidebarAppearance.fontSize", defaultValue: "Sidebar Font Size"), "font size text scale workspace title badge metadata shortcut hint sidebar-font-size"), setting(.sidebarAppearance, "hide-sidebar-details", String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), "workspace sidebar compact"), setting(.sidebarAppearance, "wrap-workspace-titles", String(localized: "settings.app.wrapWorkspaceTitles", defaultValue: "Wrap Workspace Titles in Sidebar"), "workspace title wrap multiline pr pull request"), setting(.sidebarAppearance, "show-workspace-description", String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), "workspace description notes markdown"), @@ -466,6 +468,8 @@ enum SettingsSearchIndex { "sidebar.showLog": settingID(for: .sidebarAppearance, idSuffix: "show-log"), "sidebar.showProgress": settingID(for: .sidebarAppearance, idSuffix: "show-progress"), "sidebar.showCustomMetadata": settingID(for: .sidebarAppearance, idSuffix: "show-metadata"), + "sidebar-font-size": settingID(for: .sidebarAppearance, idSuffix: "font-size"), + "surface-tab-bar-font-size": settingID(for: .terminal, idSuffix: "tab-bar-font-size"), "terminal.showScrollBar": settingID(for: .terminal, idSuffix: "scrollbar"), "terminal.showTextBoxOnNewTerminals": settingID(for: .textBox, idSuffix: "show-textbox-new-terminals"), "terminal.focusTextBoxOnNewTerminals": settingID(for: .textBox, idSuffix: "focus-textbox-new-terminals"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index ff4b262bd0..db55caaf5f 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -82,11 +82,13 @@ enum SettingsSearchAliasIndex { "app:palette-search-all": localized("settings.search.alias.setting.app.palette-search-all", defaultValue: "app.commandPaletteSearchesAllSurfaces command palette search all surfaces cmd-p terminal browser markdown"), "terminal:scrollbar": localized("settings.search.alias.setting.terminal.scrollbar", defaultValue: "terminal.showScrollBar scrollback scrollbar scroll bar right edge alternate screen tui"), "terminal:copy-on-select": localized("settings.search.alias.setting.terminal.copy-on-select", defaultValue: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"), + "terminal:tab-bar-font-size": localized("settings.search.alias.setting.terminal.tab-bar-font-size", defaultValue: "surface-tab-bar-font-size tab bar font size text scale terminal browser pane tab title"), "terminal:resume-commands": localized("settings.search.alias.setting.terminal.resume-commands", defaultValue: "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process"), "textBox:show-textbox-new-terminals": localized("settings.search.alias.setting.textBox.show-textbox-new-terminals", defaultValue: "terminal.showTextBoxOnNewTerminals show textbox text box rich input prompt default new terminal workspace split tab beta"), "textBox:focus-textbox-new-terminals": localized("settings.search.alias.setting.textBox.focus-textbox-new-terminals", defaultValue: "terminal.focusTextBoxOnNewTerminals focus textbox text box rich input prompt default new terminal workspace split tab beta"), "textBox:textbox-max-lines": localized("settings.search.alias.setting.textBox.textbox-max-lines", defaultValue: "terminal.textBoxMaxLines textbox text box rich input prompt max height lines grow scroll beta"), "sidebarAppearance:match-terminal": localized("settings.search.alias.setting.sidebarAppearance.match-terminal", defaultValue: "sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), + "sidebarAppearance:font-size": localized("settings.search.alias.setting.sidebarAppearance.font-size", defaultValue: "sidebar-font-size sidebar font size text scale workspace title badge metadata shortcut hint"), "sidebarAppearance:hide-sidebar-details": localized("settings.search.alias.setting.app.hide-sidebar-details", defaultValue: "sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), "sidebarAppearance:wrap-workspace-titles": localized("settings.search.alias.setting.app.wrap-workspace-titles", defaultValue: "sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"), "sidebarAppearance:show-workspace-description": localized("settings.search.alias.setting.app.show-workspace-description", defaultValue: "sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), diff --git a/Sources/ShortcutHintPill.swift b/Sources/ShortcutHintPill.swift index 0e04c0bb83..c88dda5049 100644 --- a/Sources/ShortcutHintPill.swift +++ b/Sources/ShortcutHintPill.swift @@ -70,11 +70,12 @@ extension View { text: String?, emphasis: Double, offsetX: Double, - offsetY: Double + offsetY: Double, + fontSize: CGFloat = 10 ) -> some View { overlay(alignment: .topTrailing) { if let text { - ShortcutHintPill(text: text, fontSize: 10, emphasis: emphasis) + ShortcutHintPill(text: text, fontSize: fontSize, emphasis: emphasis) .offset( x: ShortcutHintDebugSettings.clamped(offsetX), y: ShortcutHintDebugSettings.clamped(offsetY) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 8503dcc628..680e07ecc4 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -7,6 +7,13 @@ enum SidebarMatchTerminalBackgroundSettings { static let legacyAppliedSettingsFileDefaultKey = "cmux.settingsFile.sidebarMatchTerminalBackground.appliedDefault.v1" } +enum SidebarTabItemFontScale { + static func scale(for sidebarFontSize: CGFloat) -> CGFloat { + GhosttyConfig.clampedSidebarFontSize(sidebarFontSize) + / GhosttyConfig.defaultSidebarFontSize + } +} + extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) diff --git a/Sources/Sidebar/SidebarDirectoryText.swift b/Sources/Sidebar/SidebarDirectoryText.swift index 2550720c40..0449bdcd4b 100644 --- a/Sources/Sidebar/SidebarDirectoryText.swift +++ b/Sources/Sidebar/SidebarDirectoryText.swift @@ -9,11 +9,12 @@ import SwiftUI struct SidebarDirectoryText: View { let candidates: [String] let color: Color + var fontScale: CGFloat = 1 var body: some View { if candidates.count <= 1 { Text(candidates.first ?? "") - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(color) .lineLimit(1) .truncationMode(.tail) @@ -21,13 +22,13 @@ struct SidebarDirectoryText: View { ViewThatFits(in: .horizontal) { ForEach(Array(candidates.dropLast().enumerated()), id: \.offset) { _, candidate in Text(candidate) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(color) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) } Text(candidates.last ?? "") - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 10 * fontScale, design: .monospaced)) .foregroundColor(color) .lineLimit(1) .truncationMode(.tail) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 4d01e943f1..49a8798489 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -5,6 +5,7 @@ import CoreText import WebKit import Darwin import SwiftUI +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -4545,6 +4546,187 @@ final class SidebarBackgroundConfigTests: XCTestCase { } } +@Suite +struct SidebarFontSizeConfigTests { + @Test func defaultSidebarFontSizeMatchesSidebarTitleBaseline() { + let config = GhosttyConfig() + + #expect(abs(config.sidebarFontSize - 12.5) <= 0.0001) + #expect(abs(config.sidebarFontSize - GhosttyConfig.defaultSidebarFontSize) <= 0.0001) + } + + @Test func parseSidebarFontSizeIntegerValue() { + var config = GhosttyConfig() + + config.parse("sidebar-font-size = 14") + + #expect(abs(config.sidebarFontSize - 14) <= 0.0001) + } + + @Test func parseSidebarFontSizeFractionalValue() { + var config = GhosttyConfig() + + config.parse("sidebar-font-size = 13.75") + + #expect(abs(config.sidebarFontSize - 13.75) <= 0.0001) + } + + @Test func parseSidebarFontSizeClampsBelowMinimum() { + var config = GhosttyConfig() + + config.parse("sidebar-font-size = 4") + + #expect(abs(config.sidebarFontSize - GhosttyConfig.minSidebarFontSize) <= 0.0001) + } + + @Test func parseSidebarFontSizeClampsAboveMaximum() { + var config = GhosttyConfig() + + config.parse("sidebar-font-size = 48") + + #expect(abs(config.sidebarFontSize - GhosttyConfig.maxSidebarFontSize) <= 0.0001) + } + + @Test func parseSidebarFontSizeIgnoresInvalidAndNonFiniteValues() { + var config = GhosttyConfig() + + config.parse("sidebar-font-size = 14") + config.parse( + """ + sidebar-font-size = not-a-number + sidebar-font-size = nan + sidebar-font-size = inf + """ + ) + + #expect(abs(config.sidebarFontSize - 14) <= 0.0001) + } + + @Test func loadUsesParsedSidebarFontSizeFromInjectedLoader() { + let loaded = GhosttyConfig.load( + preferredColorScheme: .dark, + useCache: false, + loadFromDisk: { _ in + var config = GhosttyConfig() + config.parse("sidebar-font-size = 15") + return config + } + ) + + #expect(abs(loaded.sidebarFontSize - 15) <= 0.0001) + } +} + +@Suite +struct SurfaceTabBarFontSizeConfigTests { + @Test func defaultSurfaceTabBarFontSizeMatchesBaseline() { + let config = GhosttyConfig() + + #expect(abs(config.surfaceTabBarFontSize - 11) <= 0.0001) + #expect(abs(config.surfaceTabBarFontSize - GhosttyConfig.defaultSurfaceTabBarFontSize) <= 0.0001) + } + + @Test func parseSurfaceTabBarFontSizeIntegerValue() { + var config = GhosttyConfig() + + config.parse("surface-tab-bar-font-size = 14") + + #expect(abs(config.surfaceTabBarFontSize - 14) <= 0.0001) + } + + @Test func parseSurfaceTabBarFontSizeFractionalValue() { + var config = GhosttyConfig() + + config.parse("surface-tab-bar-font-size = 12.5") + + #expect(abs(config.surfaceTabBarFontSize - 12.5) <= 0.0001) + } + + @Test func parseSurfaceTabBarFontSizeClampsBelowMinimum() { + var config = GhosttyConfig() + + config.parse("surface-tab-bar-font-size = 4") + + #expect(abs(config.surfaceTabBarFontSize - GhosttyConfig.minSurfaceTabBarFontSize) <= 0.0001) + } + + @Test func parseSurfaceTabBarFontSizeClampsAboveMaximum() { + var config = GhosttyConfig() + + config.parse("surface-tab-bar-font-size = 48") + + #expect(abs(config.surfaceTabBarFontSize - GhosttyConfig.maxSurfaceTabBarFontSize) <= 0.0001) + } + + @Test func parseSurfaceTabBarFontSizeIgnoresInvalidAndNonFiniteValues() { + var config = GhosttyConfig() + + config.parse("surface-tab-bar-font-size = 14") + config.parse( + """ + surface-tab-bar-font-size = not-a-number + surface-tab-bar-font-size = nan + surface-tab-bar-font-size = inf + """ + ) + + #expect(abs(config.surfaceTabBarFontSize - 14) <= 0.0001) + } + + @Test func loadUsesParsedSurfaceTabBarFontSizeFromInjectedLoader() { + let loaded = GhosttyConfig.load( + preferredColorScheme: .dark, + useCache: false, + loadFromDisk: { _ in + var config = GhosttyConfig() + config.parse("surface-tab-bar-font-size = 14") + return config + } + ) + + #expect(abs(loaded.surfaceTabBarFontSize - 14) <= 0.0001) + } + + @Test func editorParsesLastSurfaceTabBarValueAndClamps() { + let contents = """ + surface-tab-bar-font-size = 9 + surface-tab-bar-font-size = 40 + """ + + #expect(CmuxGhosttyConfigSettingEditor.parsedSurfaceTabBarFontSize(in: contents) + == CmuxGhosttyConfigSettingEditor.maxSurfaceTabBarFontSize) + } + + @Test func editorReturnsNilWhenSurfaceTabBarValueAbsent() { + #expect(CmuxGhosttyConfigSettingEditor.parsedSurfaceTabBarFontSize(in: "sidebar-font-size = 14") == nil) + } + + @Test func editorFormatsSurfaceTabBarValueTrimmingTrailingZeros() { + #expect(CmuxGhosttyConfigSettingEditor.formattedSurfaceTabBarFontSize(12) == "12") + #expect(CmuxGhosttyConfigSettingEditor.formattedSurfaceTabBarFontSize(12.5) == "12.5") + } + + @Test func editorWriteSettingRoundTripsSurfaceTabBarValue() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-surface-tab-bar-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("config.ghostty") + try "font-size = 13\n".write(to: url, atomically: true, encoding: .utf8) + + try CmuxGhosttyConfigSettingEditor.writeSetting( + key: CmuxGhosttyConfigSettingEditor.surfaceTabBarFontSizeKey, + value: "13", + to: url + ) + + let contents = try String(contentsOf: url, encoding: .utf8) + #expect(contents.contains("surface-tab-bar-font-size = 13")) + #expect(contents.contains("font-size = 13")) + #expect(CmuxGhosttyConfigSettingEditor.parsedSurfaceTabBarFontSize(in: contents) == 13) + } +} + final class ZshShellIntegrationHandoffTests: XCTestCase { func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws { let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true) diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index da58fd0761..d63519c382 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -6,6 +6,7 @@ import WebKit import ObjectiveC.runtime import Bonsplit import UserNotifications +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -115,6 +116,39 @@ final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { } } +@Suite +struct SidebarTabItemFontScaleTests { + @Test func defaultSidebarFontScaleIsUnitScale() { + let scale = SidebarTabItemFontScale.scale(for: GhosttyConfig.defaultSidebarFontSize) + + #expect(abs(scale - 1) <= 0.0001) + } + + @Test func sidebarFontScaleIsProportionalToDefaultSidebarSize() { + let scale = SidebarTabItemFontScale.scale(for: 18) + + #expect(abs(scale - (18 / GhosttyConfig.defaultSidebarFontSize)) <= 0.0001) + } + + @Test func sidebarFontScaleClampsSmallSizes() { + let scale = SidebarTabItemFontScale.scale(for: 4) + + #expect(abs(scale - (GhosttyConfig.minSidebarFontSize / GhosttyConfig.defaultSidebarFontSize)) <= 0.0001) + } + + @Test func sidebarFontScaleClampsLargeSizes() { + let scale = SidebarTabItemFontScale.scale(for: 48) + + #expect(abs(scale - (GhosttyConfig.maxSidebarFontSize / GhosttyConfig.defaultSidebarFontSize)) <= 0.0001) + } + + @Test func sidebarFontScaleFallsBackToDefaultForNonFiniteValue() { + let scale = SidebarTabItemFontScale.scale(for: CGFloat.nan) + + #expect(abs(scale - 1) <= 0.0001) + } +} + final class SidebarRemoteErrorCopySupportTests: XCTestCase { func testMenuLabelIsNilWhenThereAreNoErrors() { diff --git a/docs/cli-contract.md b/docs/cli-contract.md index b8f099feb5..a3a7697edf 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -333,6 +333,13 @@ Config subcommands: | `config path`, `config paths` | Print cmux.json paths, docs URL, schema URL, backup reminder, and reload command without a socket. | | `config docs`, `config documentation` | Print the same output as `docs settings` without a socket. | | `config reload` | Ask the running cmux app to reload configuration. Requires a socket. | +| `config get sidebar-font-size` | Print the effective sidebar text size. | +| `config set sidebar-font-size ` | Write the sidebar text size to cmux's editable Ghostty config and reload the running app when available. | +| `config sidebar-font-size [points]` | Get the sidebar text size, or set it when a point size is provided. | +| `config get surface-tab-bar-font-size` | Print the effective workspace tab bar text size. | +| `config set surface-tab-bar-font-size ` | Write the workspace tab bar text size to cmux's editable Ghostty config and reload the running app when available. | +| `config surface-tab-bar-font-size [points]` | Get the workspace tab bar text size, or set it when a point size is provided. | +| `config get `, `config set ` | Generic get/set for `sidebar-font-size` and `surface-tab-bar-font-size`. | `config doctor --json` outputs an object with `ok`, `error_count`, `findings`, `reload_command`, `docs_url`, and `schema_url`. Each finding includes @@ -389,7 +396,7 @@ the expected text without connecting to a cmux socket. - `cmux settings --help` -> `Usage: cmux settings [open [target]|path|docs|]` - `cmux settings path` -> `Config files:` - `cmux settings docs` -> `Config files:` -- `cmux config --help` -> `Usage: cmux config ` +- `cmux config --help` -> `Usage: cmux config ` - `cmux config path` -> `Config files:` - `cmux config docs` -> `Config files:` - `cmux welcome --help` -> `Usage: cmux welcome` diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index aed4fbf4ac..6c18f12954 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -34,6 +34,20 @@ target_arch_for_triple() { esac } +# Real host arch, accounting for Rosetta where `uname -m` reports x86_64 on +# Apple Silicon. Used so the default single-arch stub targets the true host. +detected_host_arch() { + local host_arch="" + case "$(uname -m)" in + arm64 | aarch64) host_arch="arm64" ;; + x86_64) host_arch="x86_64" ;; + esac + if [[ "$host_arch" == "x86_64" && "$(sysctl -in hw.optional.arm64 2>/dev/null || echo 0)" == "1" ]]; then + host_arch="arm64" + fi + echo "$host_arch" +} + zig_has_required_version() { local zig_path="$1" [[ -x "$zig_path" ]] || return 1 @@ -44,6 +58,8 @@ select_zig_for_target() { local target="${1:-}" local desired_arch desired_arch="$(target_arch_for_triple "$target")" + local host_arch + host_arch="$(detected_host_arch)" if [[ -n "${CMUX_ZIG:-}" ]]; then if [[ ! -x "$CMUX_ZIG" ]]; then @@ -59,12 +75,19 @@ select_zig_for_target() { fi local -a candidates=() + # Prefer Apple Silicon Homebrew Zig on macOS runners. Some CI shells expose + # /usr/local/bin first or run under Rosetta, but the x86_64 Zig link path can + # fail against newer macOS SDKs while arm64 Zig cross-compiles both slices. + candidates+=("/opt/homebrew/bin/zig") local path_zig="" path_zig="$(command -v zig 2>/dev/null || true)" [[ -n "$path_zig" ]] && candidates+=("$path_zig") - candidates+=("/opt/homebrew/bin/zig" "/usr/local/bin/zig") + candidates+=("/usr/local/bin/zig") local fallback="" + local host_match="" + local desired_match="" + local apple_silicon_match="" local seen=" " local candidate="" local canonical="" @@ -76,15 +99,35 @@ select_zig_for_target() { seen="${seen}${canonical} " zig_has_required_version "$canonical" || continue [[ -z "$fallback" ]] && fallback="$canonical" - if [[ -n "$desired_arch" ]]; then - arch="$(zig_binary_arch "$canonical")" - if [[ "$arch" == "$desired_arch" ]]; then - echo "$canonical" - return 0 - fi + arch="$(zig_binary_arch "$canonical")" + if [[ -z "$apple_silicon_match" && "$arch" == "arm64" ]]; then + apple_silicon_match="$canonical" + fi + if [[ -n "$host_arch" && -z "$host_match" && "$arch" == "$host_arch" ]]; then + host_match="$canonical" + fi + if [[ -n "$desired_arch" && -z "$desired_match" && "$arch" == "$desired_arch" ]]; then + desired_match="$canonical" fi done + # Prefer the arm64 Zig when it exists because it can cross-compile the x86_64 + # helper slice and avoids Rosetta linker failures on macOS CI runners. + if [[ -n "$apple_silicon_match" ]]; then + echo "$apple_silicon_match" + return 0 + fi + + if [[ -n "$desired_match" ]]; then + echo "$desired_match" + return 0 + fi + + if [[ -n "$host_match" ]]; then + echo "$host_match" + return 0 + fi + if [[ -n "$fallback" ]]; then echo "$fallback" return 0 @@ -126,16 +169,6 @@ if [[ -z "$OUTPUT_PATH" ]]; then exit 1 fi -# Allow CI to skip the zig build (e.g., macOS 26 where zig 0.15.2 can't link). -# Creates a stub binary so the Xcode Run Script file-existence check passes. -if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then - echo "Skipping zig CLI helper build (CMUX_SKIP_ZIG_BUILD=1)" - mkdir -p "$(dirname "$OUTPUT_PATH")" - printf '#!/bin/sh\necho "ghostty CLI helper stub (zig build skipped)" >&2\nexit 1\n' > "$OUTPUT_PATH" - chmod +x "$OUTPUT_PATH" - exit 0 -fi - if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then echo "--universal and --target are mutually exclusive" >&2 usage >&2 @@ -153,6 +186,47 @@ if [[ -n "$TARGET_TRIPLE" ]]; then esac fi +write_macho_stub() { + local output="$1" + local clang_target="$2" + local tmp_dir="$3" + local source="$tmp_dir/ghostty-stub.c" + cat > "$source" <<'EOF' +#include + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + fputs("ghostty CLI helper stub (zig build skipped)\n", stderr); + return 1; +} +EOF + xcrun clang -target "$clang_target" -mmacosx-version-min=14.0 "$source" -o "$output" +} + +# Allow CI to skip the Zig helper build where only a valid app bundle shape is +# required. The stub is a Mach-O binary so architecture validation still checks +# the bundle layout and slices instead of accepting a shell script placeholder. +if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then + echo "Skipping zig CLI helper build (CMUX_SKIP_ZIG_BUILD=1)" + mkdir -p "$(dirname "$OUTPUT_PATH")" + STUB_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-ghostty-helper-stub.XXXXXX")" + trap 'rm -rf "$STUB_TMP_DIR"' EXIT + if [[ "$UNIVERSAL" == "true" ]]; then + write_macho_stub "$STUB_TMP_DIR/ghostty-arm64" "arm64-apple-macos14" "$STUB_TMP_DIR" + write_macho_stub "$STUB_TMP_DIR/ghostty-x86_64" "x86_64-apple-macos14" "$STUB_TMP_DIR" + /usr/bin/lipo -create "$STUB_TMP_DIR/ghostty-arm64" "$STUB_TMP_DIR/ghostty-x86_64" -output "$OUTPUT_PATH" + else + case "$TARGET_TRIPLE" in + aarch64-macos) write_macho_stub "$OUTPUT_PATH" "arm64-apple-macos14" "$STUB_TMP_DIR" ;; + x86_64-macos) write_macho_stub "$OUTPUT_PATH" "x86_64-apple-macos14" "$STUB_TMP_DIR" ;; + *) write_macho_stub "$OUTPUT_PATH" "$(detected_host_arch)-apple-macos14" "$STUB_TMP_DIR" ;; + esac + fi + chmod +x "$OUTPUT_PATH" + exit 0 +fi + if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2 exit 1 @@ -196,7 +270,10 @@ build_helper() { echo "Building Ghostty CLI helper with $zig_bin${target:+ for $target}" ( cd "$GHOSTTY_DIR" - "${args[@]}" + # Zig 0.15.x treats SDKROOT as a sysroot override. Xcode exports SDKROOT to + # the macOS SDK, which makes Zig look for SDK paths under that SDK again and + # leaves build-runner binaries unlinked against libSystem on a cold cache. + env -u SDKROOT "${args[@]}" ) } diff --git a/vendor/bonsplit b/vendor/bonsplit index 9166c3639f..ddb46fe94f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 9166c3639fabb80bca050d57d7cbf3fe66248c2e +Subproject commit ddb46fe94fbbd1efd062d80371ca2455c4296eb5 diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 0e8538d8b0..0ae86c9dc8 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -285,6 +285,8 @@ touch ~/.config/ghostty/config`} {t("exampleConfig")} {`font-family = SF Mono font-size = 13 +sidebar-font-size = 14 +surface-tab-bar-font-size = 11 theme = One Dark scrollback-limit = 50000000 split-divider-color = #3e4451