Skip to content

Add global UI scale setting and zoom shortcuts (#3862) - #3864

Closed
austinywang wants to merge 48 commits into
mainfrom
issue-3862-global-ui-font-size
Closed

austinywang wants to merge 48 commits into
mainfrom
issue-3862-global-ui-font-size

Conversation

@austinywang

@austinywang austinywang commented May 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3862

Summary

  • Add app.uiScale as a clamped global UI scale loaded from and persisted to cmux.json
  • Add customizable UI zoom shortcuts: Cmd+Shift+=, Cmd+Shift+-, Cmd+Shift+0
  • Flow uiScaleFactor through SwiftUI environment and scale built-in chrome surfaces including sidebars, file browser, settings, markdown, and task manager
  • Complete localization coverage for the new UI scale strings across all locales already present in Localizable.xcstrings

Testing

  • Test-first commit added regression coverage before implementation
  • Static diff check passed: git diff --check
  • Localizable.xcstrings parsed as JSON and all 9 new UI scale keys were verified to include the expected 19-locale coverage
  • Local test/build not run per repository/user policy; CI owns test execution

Demo Video

  • Not applicable for this enhancement PR; no crash or visual bug reproduction video was required.
  • Tagged dev app launch will be done after CI is green, per the branch handoff order.

Note

Medium Risk
Touches cross-cutting UI layout/typography and introduces new settings-file persistence/editing logic, which could cause regressions in sizing, shortcut routing, or config writes if edge cases are missed.

Overview
Adds a new global UI scaling setting (app.uiScale) that is clamped, stored in UserDefaults, persisted to cmux.json, and injected via a uiScaleFactor SwiftUI environment value from the main window root.

Introduces configurable UI Zoom In/Out/Reset shortcuts and routes them through the app’s key-event handling (including AppKit key-equivalent fallbacks) while suppressing them when the command palette is effectively visible.

Applies UI scale throughout built-in chrome (sidebars, right sidebar controls, file explorer AppKit views, markdown web preview base font size, various headers/popovers) via scaled metrics/fonts, updates settings/search indexing to surface the new option, and adds full localization strings/aliases for the new UI scale and menu items.

Reviewed by Cursor Bugbot for commit 5da03c9. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds a global UI scale setting in cmux.json with app‑wide Zoom In/Out/Reset shortcuts. Scales built‑in chrome across the app independent of terminal font size or browser zoom (Linear 3862).

  • New Features

    • app.uiScale (default 1.0, clamp 0.7–2.0) with a slider and customizable shortcuts (Cmd+Shift+=/−/0).
    • App‑global uiScaleFactor injected from app roots and applied across SwiftUI/AppKit chrome: sidebars, file explorer (row height/indent/icons/header), right‑sidebar mode bar/controls, settings, markdown base font size, session index and transcript popovers, task manager, popovers, and the shortcut hint pill.
    • Schema/search updates and docs (web/data/cmux.schema.json, web/data/cmux-shortcuts.ts); JSONC‑preserving settings editor; tests (UIScaleSettingsTests) cover cmux.json round‑trip, environment propagation, and clamp/reset via shortcuts.
  • Bug Fixes

    • Keep zoom shortcuts app‑global, return handled status, and disable while the Command Palette is open.
    • Safer persistence: JSONC‑preserving edits; debounced, serialized background writes that skip stale values; main‑thread reloads gated to newer values; “Reset all” uses the same path; do not overwrite externally managed UI‑scale changes; resolve Sendable warnings.
    • Preserve file explorer expansion/selection and avoid redundant layout on scale changes.
    • Align chrome sizing and typography: right‑sidebar metrics, session index and captions, transcript popovers/body fonts, unread badges, PR icon geometry, and popover loading text; Task Manager now hosts a UI‑scale root wrapper so live changes apply.
    • Fix session popover tests by passing uiScaleFactor.

Written for commit 5da03c9. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features

    • App-wide UI Scale: settings slider, persistent value, and global zoom shortcuts (Cmd+Shift+=, Cmd+Shift+-, Cmd+Shift+0).
  • UI Improvements

    • UI scaling applied across sidebars, panels, file explorer, task manager, markdown, toolbars, and other chrome for consistent typography, iconography, and spacing.
  • Localization

    • Updated English/Japanese strings and new localized entries/search aliases for UI scale and zoom shortcuts.
  • Tests

    • Added tests for scale persistence, environment propagation, and shortcut behavior.

Review Change Stack

Add regression coverage for the requested UI scale behavior before wiring the production path. The tests cover cmux.json round-trip persistence, root environment propagation into a representative sidebar view, and shortcut-driven clamp/reset behavior.

Constraint: Test-first policy for issue 3862 requires this commit to fail before the fix commit lands
Confidence: high
Scope-risk: narrow
Tested: Not run locally per repository policy
Not-tested: CI execution pending after implementation
Issue 3862 needs UI zoom to be independent from terminal font size and browser zoom. This adds app.uiScale as the single persisted setting, injects a clamped environment value from the app roots, and routes the settings slider plus customizable Cmd-Shift zoom shortcuts through the same persistence path.

The scale is applied to the sidebar chrome/workspace rows, right sidebar mode bar, file browser AppKit rows/search/header, settings chrome, markdown panel, and task manager so built-in UI surfaces follow one proportional value instead of new per-view constants.

Constraint: Do not change Ghostty font-size or browser zoom shortcuts
Constraint: Local tests are not run by policy; CI owns test execution
Rejected: Per-surface font-size keys for v1 | would duplicate state before the global scale source of truth exists
Confidence: medium
Scope-risk: moderate
Tested: Static diff check and JSON validation with jq
Not-tested: Local XCTest/build per repository and user policy
@vercel

vercel Bot commented May 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 22, 2026 8:58pm
cmux-staging Building Building Preview, Comment May 22, 2026 8:58pm

@coderabbitai

coderabbitai Bot commented May 11, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a persisted UI scale setting with UIScaleSettings, three UI-zoom shortcuts and AppDelegate interception, settings-file persistence and schema, environment injection of uiScaleFactor, scaled font helpers, broad view updates to honor scaling, tests, and project wiring.

Changes

UI Scale Feature

Layer / File(s) Summary
Core UI Scale State
Sources/UIScaleSettings.swift
New UIScaleSettings manages persisted scale, clamping/rounding, notifications, environment key, and provides cmuxFont/cmuxSystemFont helpers.
Keyboard Shortcut Actions
Sources/KeyboardShortcutSettings.swift, web/data/cmux-shortcuts.ts
Adds .uiScaleZoomIn, .uiScaleZoomOut, .uiScaleReset with localized labels and defaults (Cmd+Shift+=, Cmd+Shift+-, Cmd+Shift+0); web shortcuts metadata updated.
AppDelegate Shortcut Routing
Sources/AppDelegate.swift
Intercepts UI-scale shortcuts early (handleUIScaleKeyEquivalent/handleUIScaleShortcut) and checks them from NSWindow.cmux_performKeyEquivalent so events are consumed before other routing; Command Palette blocking is respected.
Settings File Persistence
Sources/KeyboardShortcutSettingsFileStore.swift, Sources/CmuxSettingsJSONPathSupport.swift, Sources/KeyboardShortcutSettingsFileStore+Template.swift
Adds app.uiScale JSON path, writeAppUIScale(_:) to atomically write app.uiScale to cmux.json with schema fields and clamped numeric parsing; default template includes uiScale.
App-Level Integration
Sources/cmuxApp.swift, Sources/TaskManagerWindowController.swift
App stores raw uiScaleRaw, computes clamped uiScaleFactor, injects it into SwiftUI environments (main/settings/config), replaces browser zoom commands with UI zoom commands, and exposes a UI Scale slider in SettingsView.
Sidebar & Content View Scaling
Sources/ContentView.swift
VerticalTabsSidebar/TabItemView propagate uiScaleFactor (Equatable updated); sidebar rows and icons use UIScaleSettings.scaled/cmuxFont for typography and sizing.
File Explorer Scaling
Sources/FileExplorerStore.swift, Sources/FileExplorerView.swift
FileExplorerStyle adds scaled helpers; FileExplorerPanelView/FileExplorerContainerView/Coordinator propagate uiScaleFactor, update constraints, row heights, indentation, and pass scale into cell configuration.
Session / Panels / Misc Views
Sources/SessionIndexView.swift, Sources/Panels/MarkdownPanelView.swift, Sources/RightSidebarPanelView.swift, Sources/ShortcutHintPill.swift, Sources/TaskManagerView.swift, Sources/Settings/ConfigSettingsView.swift
Multiple views read uiScaleFactor from environment and apply scaled fonts/icons via cmuxFont and UIScaleSettings.scaled; some views add stored uiScaleFactor and include it in Equatable.
Localization & Schema
Resources/Localizable.xcstrings, Sources/SettingsNavigation.swift, Sources/SettingsSearchAliases.swift, web/data/cmux.schema.json
Adds localized strings for settings label/description, menu/shortcut labels, and search alias for UI scale; extends JSON schema with app.uiScale and allows new shortcut action ids.
Tests
cmuxTests/UIScaleSettingsTests.swift
Tests validate settings-file round-trip persistence, environment propagation to a probe view with scaled metrics, and shortcut-driven clamp/reset behavior with synthesized key events.
Project Wiring
GhosttyTabs.xcodeproj/project.pbxproj
Adds UIScaleSettings and its tests to project file references and build phases so sources/tests compile.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

"A rabbit tunes each font and badge,
sliders hum and shortcuts badge;
zoom in, zoom out, reset with cheer,
views grow close or shrink to near—
hop in, adjust, the UI’s glad!"


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error PR introduces asyncAfter (0.15s debounce) and NSLock in production code violating swift-blocking-runtime.md, which explicitly fails these patterns by default. Remove asyncAfter from key event path; use actor-based coordination or async sequences for persistence instead of NSLock.
Cmux Swift Concurrency ❌ Error UIScaleSettings introduces new background DispatchQueue for file I/O debouncing, violating concurrency modernization guidelines. Replace DispatchQueue.asyncAfter debouncing with async/await and Task cancellation for file I/O work.
Cmux Architecture Rethink ❌ Error UIScaleSettings uses debounced asyncAfter with NSLock for mutable side-channel state. Timing repair pattern violates architectural rules against delayed dispatch, locks, and observers. Use actor-based persistence or move debouncing to app layer. Unify @AppStorage usage (cmuxApp and SettingsView both use same key). Establish single source of truth for UIScale state.
Docstring Coverage ⚠️ Warning Docstring coverage is 6.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (11 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: adding a global UI scale setting and associated zoom shortcuts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed No actor isolation violations. Mutable state is lock-protected, AppDelegate maintains @MainActor, and thread-safe methods don't access shared mutable state.
Cmux No Hacky Sleeps ✅ Passed No production non-Swift code contains problematic sleeps/delays. Polling found is test-only deterministic scaffolding with bounded timeout, explicitly allowed by rule.
Cmux Swift @Concurrent ✅ Passed All Swift concurrent annotation rules are followed. New code properly handles file I/O via background queues with explicit main-thread hops. No @concurrent violations or missing annotations detected.
Cmux Swift File And Package Boundaries ✅ Passed UIScaleSettings.swift (144 lines) cohesive and under threshold. AppDelegate routing additions fit allowed exception. Large files within budget. No mixed responsibilities or package extraction needed.
Cmux Swift Logging ✅ Passed All logging complies with swift-logging.md. New logging uses: (1) unified Logger with privacy annotations, (2) #if DEBUG guards, (3) existing logInvalid helper. No print/debugPrint/dump violations.
Cmux Swiftui State Layout ✅ Passed Compliant: UIScaleSettings is enum, uses @AppStorage, environment propagation, stateless modifiers, row closure pattern, no GeometryReader for layout, no body state mutations.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new user-visible windows added. TaskManagerWindowController pre-existing with "cmux.taskManager" identifier already registered. Test fixture allowed. Lint script passes.
Description check ✅ Passed The pull request description addresses the template's required sections: Summary (what changed and why), Testing (how it was tested and what was verified), and a Demo Video section (marked N/A with justification). The checklist is completed with all boxes checked.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-3862-global-ui-font-size

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

CI failed before compiling the new UI scale implementation because the new app source and regression test were present on disk but absent from the Xcode project target membership. Add both files to the app/test source phases so the CI build exercises the intended code path.

Constraint: The requested verification path forbids local tests and direct xcodebuild, so this fix is validated with project-file linting and CI.\nConfidence: high\nScope-risk: narrow\nTested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj\nNot-tested: Local build or unit tests, per instruction
coderabbitai[bot]
coderabbitai Bot previously requested changes May 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Sources/ContentView.swift (1)

13484-13497: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Scale all PR status icon variants, not just .closed.

PullRequestStatusIcon applies uiScaleFactor only for the .closed symbol path. .open and .merged stay fixed-size, so icon sizing becomes inconsistent when UI scale changes.

Suggested fix
 private struct PullRequestStatusIcon: View {
     `@Environment`(\.uiScaleFactor) private var uiScaleFactor

     let status: SidebarPullRequestStatus
     let color: Color
     private static let frameSize: CGFloat = 12

     var body: some View {
         switch status {
         case .open:
-            PullRequestOpenIcon(color: color)
+            PullRequestOpenIcon(color: color)
+                .scaleEffect(uiScaleFactor)
         case .merged:
-            PullRequestMergedIcon(color: color)
+            PullRequestMergedIcon(color: color)
+                .scaleEffect(uiScaleFactor)
         case .closed:
             Image(systemName: "xmark.circle")
                 .font(.system(size: UIScaleSettings.scaled(7, by: uiScaleFactor), weight: .regular))
                 .foregroundColor(color)
                 .frame(
                     width: UIScaleSettings.scaled(Self.frameSize, by: uiScaleFactor),
                     height: UIScaleSettings.scaled(Self.frameSize, by: uiScaleFactor)
                 )
         }
     }
 }

Also applies to: 13501-13537, 13539-13573

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ContentView.swift` around lines 13484 - 13497, PullRequestStatusIcon
currently only scales the `.closed` branch; update the `.open` and `.merged`
branches so their icons respect uiScaleFactor too by applying the same sizing
logic used for the `.closed` case. Locate the switch in ContentView.swift (the
PullRequestStatusIcon switch with cases `.open`, `.merged`, `.closed`) and
either make PullRequestOpenIcon and PullRequestMergedIcon accept uiScaleFactor
and Self.frameSize or wrap them with the same .font, .foregroundColor, and
.frame calls that use UIScaleSettings.scaled(Self.frameSize, by: uiScaleFactor)
so all three variants are consistently sized; apply the same change to the other
occurrences noted (the ranges 13501-13537 and 13539-13573).
Sources/SessionIndexView.swift (2)

2202-2204: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

PopoverRow equality should include uiScaleFactor to prevent stale rendering on scale changes

PopoverRow uses uiScaleFactor in its body for font scaling but only compares entry in its == implementation. If the view is rendered with .equatable(), scale changes won't trigger re-renders.

Proposed fix
     static func == (lhs: PopoverRow, rhs: PopoverRow) -> Bool {
-        lhs.entry == rhs.entry
+        lhs.entry == rhs.entry &&
+            lhs.uiScaleFactor == rhs.uiScaleFactor
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SessionIndexView.swift` around lines 2202 - 2204, The Equatable
implementation for PopoverRow only compares lhs.entry to rhs.entry, which
ignores uiScaleFactor and can cause stale views when scale changes; update the
static func ==(lhs: PopoverRow, rhs: PopoverRow) to also compare
lhs.uiScaleFactor == rhs.uiScaleFactor (in addition to entry) so changes in
uiScaleFactor will trigger re-renders when using .equatable().

1659-1681: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

UI scale is not bridged into popover-hosted SwiftUI roots

Both SessionTranscriptPopoverHost and SectionPopoverHost rebuild content inside manual NSHostingController roots without injecting uiScaleFactor from the parent environment. Popovers will render with default scale even when the parent sidebar scales.

The fix requires three steps for each host:

  1. Add @Environment(\.uiScaleFactor) to the host struct
  2. Pass uiScaleFactor through the coordinator's update() method
  3. Apply .environment(\.uiScaleFactor, currentUIScaleFactor) to the hosted root view before assigning to hostingController.rootView
Proposed fix
 private struct SessionTranscriptPopoverHost: NSViewRepresentable {
+    `@Environment`(\.uiScaleFactor) private var uiScaleFactor
     `@Binding` var isPresented: Bool
     let entry: SessionEntry
@@
     func updateNSView(_ nsView: PopoverAnchorView, context: Context) {
         let coordinator = context.coordinator
         coordinator.anchorView = nsView
-        coordinator.update(entry: entry)
+        coordinator.update(entry: entry, uiScaleFactor: uiScaleFactor)
         if isPresented {
             coordinator.present()
         } else {
             coordinator.dismiss()
         }
@@
     final class Coordinator: NSObject, NSPopoverDelegate {
@@
+        private var currentUIScaleFactor: Double = 1.0
@@
-        func update(entry: SessionEntry) {
+        func update(entry: SessionEntry, uiScaleFactor: Double) {
             let shouldRefresh = currentEntry?.id != entry.id
             currentEntry = entry
+            currentUIScaleFactor = uiScaleFactor
             if shouldRefresh {
                 refreshContent()
             }
         }
@@
             hostingController.rootView = AnyView(
                 SessionTranscriptPreviewView(
                     entry: entry,
                     sizeModel: sizeModel,
                     onResize: { [weak self] proposedSize in
                         self?.resize(to: proposedSize)
                     }
                 ) { [weak self] in
                     self?.closeFromContent()
                 }
                 .id(entry.id)
+                .environment(\.uiScaleFactor, currentUIScaleFactor)
             )
         }
     }
 }

 struct SectionPopoverHost: NSViewRepresentable {
+    `@Environment`(\.uiScaleFactor) private var uiScaleFactor
     `@Binding` var isPresented: Bool
@@
     func updateNSView(_ nsView: NSView, context: Context) {
         let coordinator = context.coordinator
         coordinator.anchorView = nsView
         coordinator.update(
             section: section,
             search: search,
             loadSnapshot: loadSnapshot,
-            onResume: onResume
+            onResume: onResume,
+            uiScaleFactor: uiScaleFactor
         )
         if isPresented {
             coordinator.present()
         } else {
             coordinator.dismiss()
         }
     }
@@
     final class Coordinator: NSObject, NSPopoverDelegate {
@@
+        private var currentUIScaleFactor: Double = 1.0
@@
         func update(
             section: IndexSection,
             search: `@escaping` SessionSearchFn,
             loadSnapshot: `@escaping` DirectorySnapshotFn,
-            onResume: ((SessionEntry) -> Void)?
+            onResume: ((SessionEntry) -> Void)?,
+            uiScaleFactor: Double
         ) {
             currentSection = section
             currentSearch = search
             currentLoadSnapshot = loadSnapshot
             currentOnResume = onResume
+            currentUIScaleFactor = uiScaleFactor
             guard popover?.isShown == true else { return }
             guard lastRenderedSection != section || lastRenderedPresentationCount != presentationCount else { return }
             refreshContent()
         }
@@
             hostingController.rootView = AnyView(
                 SectionPopoverView(
                     section: section,
                     search: search,
                     loadSnapshot: loadSnapshot,
                     onResume: onResume
                 ) { [weak self] in
                     self?.closeFromContent()
                 }
                 .id(identity)
+                .environment(\.uiScaleFactor, currentUIScaleFactor)
             )
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SessionIndexView.swift` around lines 1659 - 1681,
SessionTranscriptPopoverHost and SectionPopoverHost create NSHostingController
roots without inheriting the parent uiScaleFactor, so add
`@Environment`(\\.uiScaleFactor) private var uiScaleFactor to each host struct,
update the Coordinator.update(...) signatures to accept the current
uiScaleFactor (e.g., update(entry:..., uiScaleFactor: Double) or similar), pass
the host's uiScaleFactor into coordinator.update(...) from updateNSView, and
before assigning hostingController.rootView set the hosted SwiftUI view with
.environment(\\.uiScaleFactor, uiScaleFactor) so the popover-hosted root
receives the same scale as the parent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/AppDelegate.swift`:
- Around line 10729-10731: The UI-scale shortcut handler
(handleUIScaleShortcut(event:)) is being invoked even when the command palette
is active; update the call sites so they first check the command palette
visibility and skip UI-scale handling while it's shown. Concretely, before
calling handleUIScaleShortcut(event:), add a guard like if
commandPaletteIsVisible() { return false } (or use the existing command palette
visibility API such as commandPaletteController?.isVisible) so the function is
not invoked when the palette is open; apply this same guard at each place that
currently calls handleUIScaleShortcut (the earlier UI-scale routing locations
mentioned) to ensure zoom shortcuts are blocked while the palette is active.

In `@Sources/SettingsNavigation.swift`:
- Line 295: The new UI Scale setting was added to the search index but not to
the navigation anchors, so add "app.uiScale" to the settingsPathAnchorIDs array
to keep path-based routing consistent; update the settingsPathAnchorIDs constant
(the collection that contains anchor IDs used for jump-to navigation) to include
"app.uiScale" alongside the other "app.*" entries so the settingsPath anchor
routing/highlighting resolves for the new setting.

In `@Sources/UIScaleSettings.swift`:
- Line 38: Replace the runtime NSLog call in UIScaleSettings with Swift's
unified Logger: add a Logger instance (e.g., a static let logger on
UIScaleSettings or a module-level Logger) and change the NSLog invocation that
references jsonPath and error to a structured logger call (e.g., logger.error or
logger.log) including both jsonPath and String(describing: error) in the
message; ensure OSLog/Logger is imported and use a clear subsystem/category for
the logger to match project conventions.

---

Outside diff comments:
In `@Sources/ContentView.swift`:
- Around line 13484-13497: PullRequestStatusIcon currently only scales the
`.closed` branch; update the `.open` and `.merged` branches so their icons
respect uiScaleFactor too by applying the same sizing logic used for the
`.closed` case. Locate the switch in ContentView.swift (the
PullRequestStatusIcon switch with cases `.open`, `.merged`, `.closed`) and
either make PullRequestOpenIcon and PullRequestMergedIcon accept uiScaleFactor
and Self.frameSize or wrap them with the same .font, .foregroundColor, and
.frame calls that use UIScaleSettings.scaled(Self.frameSize, by: uiScaleFactor)
so all three variants are consistently sized; apply the same change to the other
occurrences noted (the ranges 13501-13537 and 13539-13573).

In `@Sources/SessionIndexView.swift`:
- Around line 2202-2204: The Equatable implementation for PopoverRow only
compares lhs.entry to rhs.entry, which ignores uiScaleFactor and can cause stale
views when scale changes; update the static func ==(lhs: PopoverRow, rhs:
PopoverRow) to also compare lhs.uiScaleFactor == rhs.uiScaleFactor (in addition
to entry) so changes in uiScaleFactor will trigger re-renders when using
.equatable().
- Around line 1659-1681: SessionTranscriptPopoverHost and SectionPopoverHost
create NSHostingController roots without inheriting the parent uiScaleFactor, so
add `@Environment`(\\.uiScaleFactor) private var uiScaleFactor to each host
struct, update the Coordinator.update(...) signatures to accept the current
uiScaleFactor (e.g., update(entry:..., uiScaleFactor: Double) or similar), pass
the host's uiScaleFactor into coordinator.update(...) from updateNSView, and
before assigning hostingController.rootView set the hosted SwiftUI view with
.environment(\\.uiScaleFactor, uiScaleFactor) so the popover-hosted root
receives the same scale as the parent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fbda58aa-2fec-41ff-88ba-4203fe5580dc

📥 Commits

Reviewing files that changed from the base of the PR and between 0ce2a24 and 24d9816.

📒 Files selected for processing (23)
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/CmuxSettingsJSONPathSupport.swift
  • Sources/ContentView.swift
  • Sources/FileExplorerStore.swift
  • Sources/FileExplorerView.swift
  • Sources/KeyboardShortcutSettings.swift
  • Sources/KeyboardShortcutSettingsFileStore+Template.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/Panels/MarkdownPanelView.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/SessionIndexView.swift
  • Sources/Settings/ConfigSettingsView.swift
  • Sources/SettingsNavigation.swift
  • Sources/SettingsSearchAliases.swift
  • Sources/ShortcutHintPill.swift
  • Sources/TaskManagerView.swift
  • Sources/TaskManagerWindowController.swift
  • Sources/UIScaleSettings.swift
  • Sources/cmuxApp.swift
  • cmuxTests/UIScaleSettingsTests.swift
  • web/data/cmux-shortcuts.ts
  • web/data/cmux.schema.json

Comment thread Sources/AppDelegate.swift Outdated
Comment thread Sources/SettingsNavigation.swift
Comment thread Sources/UIScaleSettings.swift Outdated
Comment thread Sources/cmuxApp.swift Outdated
Comment thread Sources/ContentView.swift
Review feedback exposed two risks in the first pass: shortcut-triggered scale changes performed settings-file writes on the key event path, and several manually hosted or equatable views could miss the propagated scale. Move cmux.json persistence behind a serial background queue, keep shortcut routing out of command-palette state, and thread the scale into popover and Task Manager roots through SwiftUI state instead of an observer.

Constraint: Direct local tests and xcodebuild are prohibited for this task; validation must come from static checks and CI.\nRejected: Leave persistence synchronous for test determinism | it violates the keyboard event hot-path constraint.\nConfidence: high\nScope-risk: narrow\nTested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; jq empty Resources/Localizable.xcstrings web/data/cmux.schema.json web/data/cmux-settings.schema.json\nNot-tested: Local build or unit tests, per instruction
@greptile-apps

greptile-apps Bot commented May 11, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a global app.uiScale setting (0.7–2.0, default 1.0) persisted to cmux.json with JSONC-preserving edits, a debounced off-main write path, and configurable Cmd+Shift+=/−/0 zoom shortcuts. It threads uiScaleFactor through a SwiftUI environment key and applies it across sidebars, the file explorer, task manager, markdown preview, settings, and session-index popovers.

  • Persistence: UIScaleSettingsPersistenceCoordinator (Swift actor) debounces writes 150 ms; writeAppUIScaleOffMain bridges to a named DispatchQueue via withCheckedThrowingContinuation; settingsFileManagedValue guards reload() from overwriting a newer in-memory value.
  • Scale propagation: @AppStorage-backed MainWindowUIScaleRoot/TaskManagerRootView wrappers inject uiScaleFactor without notification side-channels or SwiftUI tree teardown; AppKit surfaces (file explorer rows, header, search results) read scale in updateUIScale and restore expansion state after reloadData.
  • Correctness: Popover refreshContent() calls in SectionPopoverHost and SessionTranscriptPopoverHost are gated on popover?.isShown == true; all 19 app locales are covered for every new string key.

Confidence Score: 5/5

Safe to merge; the implementation is thorough and the many regressions caught in earlier rounds have all been addressed.

The two remaining findings are both in ConfigSettingsView: a status-message label and a banner label that lost Dynamic Type adaptation when their fonts were switched to fixed-size cmuxFont calls. These do not affect the correctness of the scale feature or cause data loss, and the one-line fix (adding relativeTo:) is clear. All other previously-flagged issues — off-main I/O, generation-guarded reloads, file-explorer expansion restoration, header-height constraint, popover visibility guards, and locale coverage — are confirmed fixed in the current code.

Sources/Settings/ConfigSettingsView.swift — two font calls need relativeTo: to restore Dynamic Type adaptation.

Important Files Changed

Filename Overview
Sources/UIScaleSettings.swift Core persistence and environment key implementation; debounced actor-backed writes with OSAllocatedUnfairLock for pending-write tracking — all previously flagged concurrency issues addressed.
Sources/CmuxSettingsJSONCEditor.swift New JSONC-preserving editor for cmux.json; single-responsibility file (~500 lines) with robust string/comment/delimiter parsing and post-edit validation.
Sources/KeyboardShortcutSettingsFileStore.swift Adds writeAppUIScaleOffMain using withCheckedThrowingContinuation + named DispatchQueue; settingsFileDataForEditing uses Data(contentsOf:) with isMissingFileError guard to prevent config truncation.
Sources/Settings/ConfigSettingsView.swift Status text and banner text lose Dynamic Type adaptation: .font(.caption)/.font(.footnote) replaced with .cmuxFont(size:) without relativeTo:, unlike the same fix applied in cmuxApp.swift.
Sources/SessionIndexView.swift Both SectionPopoverHost and SessionTranscriptPopoverHost now guard refreshContent() behind popover?.isShown == true, preventing #3010-style main-thread churn on hidden popovers during scale changes.
Sources/FileExplorerView.swift updateUIScale now calls restoreExpansionState after reloadData; FileExplorerHeaderView carries a headerHeightConstraint updated with UIScaleSettings.scaled(...).
Sources/TaskManagerWindowController.swift TaskManagerRootView uses @AppStorage + environment injection instead of notification-based rootView replacement; uiScaleFactor propagates without SwiftUI tree teardown.
Resources/Localizable.xcstrings All new UI scale string keys (menu.view.uiZoomIn/Out/Reset, settings.app.uiScale, settings.app.uiScale.subtitle, settings.search.alias.setting.app.ui-scale) carry full 19-locale coverage.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["User action — slider or keyboard shortcut"] --> B["UIScaleSettings.set() on MainActor"]
    B --> C["UserDefaults update"]
    B --> D["recordPendingPersistence via OSAllocatedUnfairLock"]
    B --> E["NotificationCenter post uiScaleDidChange"]
    B --> F["Fire Task to persistence coordinator"]
    F --> G["UIScaleSettingsPersistenceCoordinator actor\n150 ms debounce via Task.sleep"]
    G --> H["writeAppUIScaleOffMain\nwithCheckedThrowingContinuation + named DispatchQueue"]
    H --> I["CmuxSettingsJSONCEditor\nJSONC-preserving patch of settings file"]
    H --> J["MainActor: completePendingPersistence → settingsFileStore.reload()"]
    C --> K["AppStorage trigger — MainWindowUIScaleRoot / TaskManagerRootView"]
    K --> L["environment uiScaleFactor injected into SwiftUI tree"]
    L --> M["SwiftUI chrome — sidebars, settings, session index, markdown"]
    L --> N["AppKit surfaces — updateUIScale, reloadData, restoreExpansionState"]
Loading

Reviews (30): Last reviewed commit: "fix: pass ui scale in session popover te..." | Re-trigger Greptile

Comment thread Sources/UIScaleSettings.swift
Comment thread Sources/UIScaleSettings.swift Outdated
Comment thread Sources/cmuxApp.swift
Comment thread Sources/cmuxApp.swift
Follow-up review found that reset-all could update only AppStorage while leaving cmux.json stale, and that PR status icons still had unscaled drawing internals. Persist reset-all through the same UI scale path, debounce background writes from live slider updates, make shortcut call sites visibly respect command-palette state, and scale PR icon geometry directly.

Constraint: Verification remains CI-only for builds/tests; local direct builds are forbidden.\nRejected: Keep only a wrapper scaleEffect around PR icons | reviewers still flagged the icon internals as fixed-size.\nConfidence: high\nScope-risk: narrow\nTested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; jq empty Resources/Localizable.xcstrings web/data/cmux.schema.json web/data/cmux-settings.schema.json\nNot-tested: Local build or unit tests, per instruction
Comment thread Sources/UIScaleSettings.swift
Comment thread Sources/AppDelegate.swift
The async persistence path must not call settings-store reload from its background disk queue. Split app.uiScale file writes from store reload so live zoom changes write cmux.json off the event path, then hop back to main for shared settings state and notifications. Also remove the redundant command-palette guard inside the already-guarded UI scale shortcut helper.

Constraint: Builds and tests must run in CI only for this task.\nRejected: Keep using persistAppUIScale from the background queue | that method also mutates store state through reload.\nConfidence: high\nScope-risk: narrow\nTested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; jq empty Resources/Localizable.xcstrings web/data/cmux.schema.json web/data/cmux-settings.schema.json\nNot-tested: Local build or unit tests, per instruction
The app-global key equivalent path must report whether the UI scale shortcut consumed the event so the activation build compiles and downstream key handling remains accurate.

Constraint: Local builds and tests are intentionally left to CI for this branch.

Confidence: high

Scope-risk: narrow

Tested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; jq empty Resources/Localizable.xcstrings web/data/cmux.schema.json web/data/cmux-settings.schema.json

Not-tested: Local xcodebuild/tests per branch instructions
Comment thread Sources/KeyboardShortcutSettingsFileStore.swift
Comment thread Sources/SessionIndexView.swift
coderabbitai[bot]
coderabbitai Bot previously requested changes May 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/KeyboardShortcutSettingsFileStore.swift`:
- Around line 166-169: persistAppUIScale currently calls reload() directly which
can run on a background queue; change it to dispatch the reload to the main
thread after the write. Specifically, inside persistAppUIScale (which calls
writeAppUIScale), wrap the reload() invocation in a main-thread dispatch (e.g.,
DispatchQueue.main.async or equivalent) so reload() always runs on the main
thread; keep the writeAppUIScale call unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a5482206-d772-4a2a-be97-c888f046dbe5

📥 Commits

Reviewing files that changed from the base of the PR and between d7183bf and bbe0885.

📒 Files selected for processing (9)
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/SessionIndexView.swift
  • Sources/SettingsNavigation.swift
  • Sources/TaskManagerWindowController.swift
  • Sources/UIScaleSettings.swift
  • Sources/cmuxApp.swift
  • cmuxTests/UIScaleSettingsTests.swift

Comment thread Sources/KeyboardShortcutSettingsFileStore.swift Outdated
The CI unit target caught test-only compile issues in the new UI scale coverage. The test helper now calls JSONCParser with its required label and polls the optional persisted value without double-unwrapping.

Constraint: Local tests are not run on this branch; CircleCI is the test runner.

Confidence: high

Scope-risk: narrow

Tested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; jq empty Resources/Localizable.xcstrings web/data/cmux.schema.json web/data/cmux-settings.schema.json

Not-tested: Local xcodebuild/tests per branch instructions
Review caught two remaining scale propagation edges: the settings-file persistence helper could reload off-main when used directly, and an equatable session icon omitted the environment scale from its equality check. The helper now schedules reload on the main queue for non-main callers, and the icon equality includes uiScaleFactor.

Constraint: Do not run local tests/builds for this branch; use CI feedback.

Confidence: high

Scope-risk: narrow

Tested: git diff --check; plutil -lint GhosttyTabs.xcodeproj/project.pbxproj; jq empty Resources/Localizable.xcstrings web/data/cmux.schema.json web/data/cmux-settings.schema.json

Not-tested: Local xcodebuild/tests per branch instructions
Comment thread Sources/Settings/ConfigSettingsView.swift
Comment thread Sources/KeyboardShortcutSettingsFileStore.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/SessionIndexView.swift (1)

2105-2116: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missed cmuxFont conversion in loadingRow.

Every other Text in SectionPopoverView was switched to .cmuxFont(size:) so it follows uiScaleFactor, but the "Loading…" label here still uses the unscaled .font(.system(size: 11)). As a result, both call sites of loadingRow (the empty-state spinner at line 1964 and the pagination sentinel at line 1986) will render at a fixed 11pt regardless of the user's UI scale, making the spinner row visually inconsistent with the rest of the popover at non-default scales.

🎯 Proposed fix
     private var loadingRow: some View {
         HStack(spacing: 6) {
             ProgressView().controlSize(.small)
             Text(String(localized: "sessionIndex.popover.loading", defaultValue: "Loading…"))
-                .font(.system(size: 11))
+                .cmuxFont(size: 11)
                 .foregroundColor(.secondary)
             Spacer(minLength: 0)
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/SessionIndexView.swift` around lines 2105 - 2116, The loadingRow
HStack in SectionPopoverView uses .font(.system(size: 11)) for the "Loading…"
Text, so it ignores the uiScaleFactor; replace that .font call by using
.cmuxFont(size:) with the scaled size (11) to match other Texts (e.g., other
uses of cmuxFont in SectionPopoverView) so the spinner row respects cmuxFont
scaling and remains consistent at non-default UI scales; update the Text in
loadingRow to use .cmuxFont(size: 11) and keep the existing
.foregroundColor(.secondary) and layout intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/SessionIndexView.swift`:
- Around line 2105-2116: The loadingRow HStack in SectionPopoverView uses
.font(.system(size: 11)) for the "Loading…" Text, so it ignores the
uiScaleFactor; replace that .font call by using .cmuxFont(size:) with the scaled
size (11) to match other Texts (e.g., other uses of cmuxFont in
SectionPopoverView) so the spinner row respects cmuxFont scaling and remains
consistent at non-default UI scales; update the Text in loadingRow to use
.cmuxFont(size: 11) and keep the existing .foregroundColor(.secondary) and
layout intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 42434040-0306-4000-8fad-a9b89d3a774a

📥 Commits

Reviewing files that changed from the base of the PR and between bbe0885 and 2c75428.

📒 Files selected for processing (3)
  • Sources/KeyboardShortcutSettingsFileStore.swift
  • Sources/SessionIndexView.swift
  • cmuxTests/UIScaleSettingsTests.swift

The global chrome scale should apply consistently to popover content, including transient loading rows, so the remaining fixed system font is routed through the same cmuxFont environment helper as adjacent rows.

Constraint: CodeRabbit found the latest head still had one fixed-size SwiftUI text modifier in SessionIndexView

Rejected: Introduce a separate loading-row scale calculation | the existing cmuxFont helper already preserves the single environment-driven path

Confidence: high

Scope-risk: narrow

Tested: git diff --check

Not-tested: Local Swift tests or app build per task instruction to use CI only before final tagged reload
The live UI scale path now writes through the debounced settings-file writer, so keeping a second direct persistence API invites stale review concerns without adding behavior.

Constraint: PR review flagged persistAppUIScale as unused after the background persistence path became the only caller-facing flow

Rejected: Keep the wrapper for possible future callers | unused settings-store APIs make thread-affinity guarantees harder to audit

Confidence: high

Scope-risk: narrow

Tested: git diff --check

Not-tested: Local Swift tests or app build per task instruction to use CI only before final tagged reload
@austinywang

Copy link
Copy Markdown
Contributor Author

Greptile summary follow-up: the two UIScaleSettings persistence issues called out in the older summary were addressed in e10f9d4. UI scale mutations now enter through @mainactor set/zoom/reset APIs, pending-write state no longer uses volatile UserDefaults domains, and settings-file JSONC writes run through writeAppUIScaleOffMain() via an explicit utility-priority Task.detached boundary.

Comment thread Sources/KeyboardShortcutSettingsFileStore.swift Outdated
Comment thread Resources/Localizable.xcstrings
Comment thread Sources/KeyboardShortcutSettingsFileStore.swift
Comment thread Sources/KeyboardShortcutSettingsFileStore.swift Outdated
…-3862-global-ui-font-size

# Conflicts:
#	Sources/AppDelegate.swift
#	Sources/ContentView.swift
Comment thread Sources/SessionIndexView.swift Outdated
Comment thread Resources/Localizable.xcstrings

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 15a356f. Configure here.

Comment thread Sources/SessionIndexView.swift
Comment thread Sources/CmuxSettingsJSONCEditor.swift Outdated

This branch was successfully deployed

1 active deployment
Preview – cmux — 5da03c94 Deployed May 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Global UI font size / zoom for sidebar and file browser

3 participants