From a1956e094cb4a855e7d48cfc28062bb939e2ffc0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 17 Sep 2026 17:35:06 -0700 Subject: [PATCH 1/3] test: terminal grid must not follow scroller visibility under legacy scrollers Adds a hosted-view behavior test that hosts a real GhosttySurfaceScrollView in an offscreen window, pins the legacy scroller style on its scroll view, and publishes Ghostty scrollbar packets the way the runtime does: history present, then emptied (the Cloud mirror's replay reset), then refilled (the replay). It asserts the terminal surface keeps the same content width throughout. On main the scroller hides and shows with scrollback, so the legacy gutter comes and goes and the grid width moves by the gutter each time; for a Cloud mirror that turns every remote `resized` replay into a new size report and an endless remote resize loop (#12885). Also adds TerminalScrollBarPresencePolicy (not yet used by the view) with its own unit test. Refs #12885 Co-Authored-By: Claude Fable 5.1 --- Sources/TerminalScrollBarPresencePolicy.swift | 39 +++++++ cmux.xcodeproj/project.pbxproj | 8 ++ ...erminalScrollBarGutterStabilityTests.swift | 106 ++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 Sources/TerminalScrollBarPresencePolicy.swift create mode 100644 cmuxTests/TerminalScrollBarGutterStabilityTests.swift diff --git a/Sources/TerminalScrollBarPresencePolicy.swift b/Sources/TerminalScrollBarPresencePolicy.swift new file mode 100644 index 000000000000..1e3647166679 --- /dev/null +++ b/Sources/TerminalScrollBarPresencePolicy.swift @@ -0,0 +1,39 @@ +import AppKit + +/// Decides whether a terminal pane's vertical scroller is present. +/// +/// Presence is a layout input, not only a visual: with the legacy scroller +/// style AppKit reserves a gutter for a present scroller, and the terminal +/// grid loses the columns underneath it. Under that style presence must not +/// follow the surface's own scrollback, or the grid becomes a function of the +/// terminal's content. A local pane then reflows when its first row scrolls +/// off (https://github.com/manaflow-ai/cmux/issues/3051), and a Cloud mirror, +/// whose reset empties history before every remote replay refills it, reports +/// a new grid after each replay; the remote PTY resizes again and sends the +/// next replay, and the loop never ends +/// (https://github.com/manaflow-ai/cmux/issues/12885). +/// +/// An overlay scroller reserves nothing, so it stays hidden while nothing can +/// scroll and never sits on top of the rightmost column of a full-screen app. +enum TerminalScrollBarPresencePolicy { + /// - Parameters: + /// - allowedBySettings: Whether the Ghostty `scrollbar` config and the + /// cmux scroll bar preference permit a scroller at all. + /// - scrollerStyle: The style the scroll view lays out with. + /// - hasScrollback: Whether the surface has rows above its viewport, or + /// nil while Ghostty has not published its first scrollbar state. + static func isPresent( + allowedBySettings: Bool, + scrollerStyle: NSScroller.Style, + hasScrollback: Bool? + ) -> Bool { + guard allowedBySettings else { return false } + // A legacy scroller is part of the layout; keep it so the grid width + // is the same with and without history. + if scrollerStyle == .legacy { return true } + // Ghostty reports scrollback asynchronously. Until the first packet + // arrives, keep the scroller so restored or reattached surfaces with + // existing scrollback do not appear broken. + return hasScrollback ?? true + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3194969027fa..c555b30365bc 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3424,6 +3424,8 @@ 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */; }; 126570000000000000000004 /* TerminalPortalTestWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */; }; D0F101000000000000000003 /* TerminalRawMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F101000000000000000013 /* TerminalRawMode.swift */; }; + C11322A10000000000012885 /* TerminalScrollBarGutterStabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322A20000000000012885 /* TerminalScrollBarGutterStabilityTests.swift */; }; + 816400000000000000012886 /* TerminalScrollBarPresencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816400000000000000012885 /* TerminalScrollBarPresencePolicy.swift */; }; D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */; }; D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */; }; 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */; }; @@ -7253,6 +7255,8 @@ 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPortalReconciliation.swift; sourceTree = ""; }; 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalPortalTestWorkspace.swift"; sourceTree = ""; }; D0F101000000000000000013 /* TerminalRawMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRawMode.swift; sourceTree = ""; }; + C11322A20000000000012885 /* TerminalScrollBarGutterStabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollBarGutterStabilityTests.swift; sourceTree = ""; }; + 816400000000000000012885 /* TerminalScrollBarPresencePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollBarPresencePolicy.swift; sourceTree = ""; }; D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalScrollSpeedSettings.swift; sourceTree = ""; }; D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollSpeedSettingsFileStoreTests.swift; sourceTree = ""; }; E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/TerminalSearchNavigation.swift; sourceTree = ""; }; @@ -9236,6 +9240,7 @@ E89950020000000000000002 /* KeyboardCopyModeSelectionKind.swift */, 85510002A1B2C3D4E5F60001 /* GhosttyApp+ChildExitPolicy.swift */, 816400000000000000000002 /* GhosttyScrollView.swift */, + 816400000000000000012885 /* TerminalScrollBarPresencePolicy.swift */, C85390010000000000000002 /* GhosttyStartupAppearancePreviewProfile.swift */, A11E5E1E0000000000000002 /* TerminalSelectionAccessibilityNotifier.swift */, A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */, @@ -10657,6 +10662,7 @@ A54730000000000000000003 /* RestoredStartupInputResendTests.swift */, A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */, C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */, + C11322A20000000000012885 /* TerminalScrollBarGutterStabilityTests.swift */, C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */, C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, @@ -14466,6 +14472,7 @@ 2681A216F02BEE5AD12CE00D /* TerminalPastePreparationWorkerTextPayload.swift in Sources */, F87910110000000000000001 /* TerminalPendingConfigurationReload.swift in Sources */, 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */, + 816400000000000000012886 /* TerminalScrollBarPresencePolicy.swift in Sources */, D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */, 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */, C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */, @@ -15958,6 +15965,7 @@ C13519000000000000000013 /* TerminalPathEnvironmentTests.swift in Sources */, C12681010000000000000001 /* TerminalPortalGeometryFixture.swift in Sources */, 126570000000000000000004 /* TerminalPortalTestWorkspace.swift in Sources */, + C11322A10000000000012885 /* TerminalScrollBarGutterStabilityTests.swift in Sources */, D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */, 222425EC9FE2E318D8F6F46A /* TerminalSearchNavigationTests.swift in Sources */, C0DE53360000000000000001 /* TerminalSearchOverlayMouseReleaseTests.swift in Sources */, diff --git a/cmuxTests/TerminalScrollBarGutterStabilityTests.swift b/cmuxTests/TerminalScrollBarGutterStabilityTests.swift new file mode 100644 index 000000000000..181fdd133adb --- /dev/null +++ b/cmuxTests/TerminalScrollBarGutterStabilityTests.swift @@ -0,0 +1,106 @@ +import AppKit +import CmuxTerminalCore +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The terminal grid must not depend on the terminal's own content. +/// +/// A legacy scroller reserves a gutter. When its presence followed scrollback, +/// a Cloud mirror whose replay reset empties history reported a new grid after +/// every remote `resized` replay, the remote PTY resized again and replayed +/// again, and Codex received a SIGWINCH storm that garbled and flickered its +/// frame (https://github.com/manaflow-ai/cmux/issues/12885). The same +/// dependency reflowed a local pane when its first row scrolled off +/// (https://github.com/manaflow-ai/cmux/issues/3051). +@MainActor +@Suite("Terminal scroll bar gutter stability", .serialized) +struct TerminalScrollBarGutterStabilityTests { + /// A pane hosted in an offscreen window so the scroll view tiles for real. + @MainActor + private final class Harness { + let window: NSWindow + let hostedView: GhosttySurfaceScrollView + let paneWidth: CGFloat = 640 + + init(scrollerStyle: NSScroller.Style) { + let surfaceView = GhosttyNSView(frame: .zero) + hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: paneWidth, height: 400), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView?.addSubview(hostedView) + hostedView.frame = window.contentView?.bounds ?? .zero + // The hosted view leaves the style to AppKit, which derives it from + // the system preference; pin it on the scroll view itself, the + // object AppKit tiles by, so the test is the same on every Mac. + let scrollView = hostedView.subviews.compactMap { $0 as? NSScrollView }.first + scrollView?.scrollerStyle = scrollerStyle + hostedView.needsLayout = true + hostedView.layoutSubtreeIfNeeded() + } + + /// Publishes one Ghostty scrollbar packet the way the runtime does and + /// returns the width the terminal surface is laid out with afterwards. + func contentWidth(after scrollbar: GhosttyScrollbar) -> CGFloat { + hostedView.surfaceView.scrollbar = scrollbar + NotificationCenter.default.post( + name: .ghosttyDidUpdateScrollbar, + object: hostedView.surfaceView, + userInfo: [GhosttyNotificationKey.scrollbar: scrollbar] + ) + hostedView.layoutSubtreeIfNeeded() + return hostedView.surfaceView.frame.width + } + } + + private static let emptyHistory = GhosttyScrollbar(total: 40, offset: 0, len: 40) + private static let withHistory = GhosttyScrollbar(total: 400, offset: 360, len: 40) + + @Test("A legacy scroller keeps the same content width with and without scrollback") + func legacyScrollerGutterDoesNotFollowScrollback() { + let harness = Harness(scrollerStyle: .legacy) + let gutter = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + + // Attach replay: history exists. Resize replay: the reset (RIS + CSI 3 J) + // empties it, then the replay refills it. The grid must not move. + let withHistory = harness.contentWidth(after: Self.withHistory) + let afterReset = harness.contentWidth(after: Self.emptyHistory) + let afterReplay = harness.contentWidth(after: Self.withHistory) + + #expect(withHistory == harness.paneWidth - gutter) + #expect(afterReset == withHistory, "the reset released the legacy gutter and widened the grid") + #expect(afterReplay == withHistory, "the replay reclaimed the legacy gutter and narrowed the grid") + } + + @Test("An overlay scroller never changes the content width") + func overlayScrollerReservesNoGutter() { + let harness = Harness(scrollerStyle: .overlay) + + let withHistory = harness.contentWidth(after: Self.withHistory) + let afterReset = harness.contentWidth(after: Self.emptyHistory) + + #expect(withHistory == harness.paneWidth) + #expect(afterReset == harness.paneWidth) + } + + @Test("Presence follows scrollback only where it reserves no layout") + func presencePolicy() { + typealias Policy = TerminalScrollBarPresencePolicy + #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: false)) + #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: nil)) + #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: true)) + #expect(!Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: false)) + #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: true)) + #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: nil)) + #expect(!Policy.isPresent(allowedBySettings: false, scrollerStyle: .legacy, hasScrollback: true)) + } +} From 08f239977fc5cc087e30651949d223ca217a67a9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:19:56 -0700 Subject: [PATCH 2/3] fix: keep terminal grid width stable across cloud replays --- .../TerminalScrollBarPresencePolicy.swift | 31 +++++++++++++++ .../Scrollbar/TerminalScrollerStyle.swift | 8 ++++ ...TerminalScrollBarPresencePolicyTests.swift | 31 +++++++++++++++ Sources/GhosttyTerminalView.swift | 19 ++++----- Sources/TerminalScrollBarPresencePolicy.swift | 39 ------------------- cmux.xcodeproj/project.pbxproj | 4 -- ...erminalScrollBarGutterStabilityTests.swift | 11 ------ 7 files changed, 77 insertions(+), 66 deletions(-) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollerStyle.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift delete mode 100644 Sources/TerminalScrollBarPresencePolicy.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift new file mode 100644 index 000000000000..d9463ab391da --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollBarPresencePolicy.swift @@ -0,0 +1,31 @@ +/// Owns the rule that decides whether a terminal scrollbar is present. +/// +/// Presence is a layout input for legacy scrollbars. Keeping that presence +/// independent of the terminal's own scrollback prevents the grid width from +/// changing when a replay temporarily empties history. +public struct TerminalScrollBarPresencePolicy: Sendable { + /// Creates a stateless scrollbar presence policy. + public init() {} + + /// Returns whether the terminal scrollbar should remain present. + /// + /// - Parameters: + /// - allowedBySettings: Whether terminal scrollbar settings allow a scrollbar. + /// - scrollerStyle: The style that lays out the terminal scroll view. + /// - hasScrollback: Whether the terminal has scrollback, or `nil` before + /// Ghostty publishes its first scrollbar state. + /// - Returns: `true` when the scroll view should keep its scrollbar present. + public func isPresent( + allowedBySettings: Bool, + scrollerStyle: TerminalScrollerStyle, + hasScrollback: Bool? + ) -> Bool { + guard allowedBySettings else { return false } + // A legacy scroller reserves layout space, so its presence must not + // follow scrollback or the terminal grid will change width. + if scrollerStyle == .legacy { return true } + // Ghostty reports scrollback asynchronously. Keep the overlay present + // until the first packet so restored surfaces do not appear broken. + return hasScrollback ?? true + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollerStyle.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollerStyle.swift new file mode 100644 index 000000000000..6309924aed23 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Scrollbar/TerminalScrollerStyle.swift @@ -0,0 +1,8 @@ +/// The two AppKit scrollbar styles that differ in whether they reserve layout space. +public enum TerminalScrollerStyle: Equatable, Sendable { + /// The classic scrollbar reserves a fixed trailing gutter in the terminal grid. + case legacy + + /// The overlay scrollbar draws over content and reserves no layout space. + case overlay +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift new file mode 100644 index 000000000000..5cf3e3db4af0 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalScrollBarPresencePolicyTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import CmuxTerminalCore + +@Suite("Terminal scroll bar presence policy") +struct TerminalScrollBarPresencePolicyTests { + @Test("legacy scrollers stay present regardless of scrollback") + func legacyStyleReservesStableGutter() { + let policy = TerminalScrollBarPresencePolicy() + + #expect(policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: false)) + #expect(policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: nil)) + #expect(policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: true)) + } + + @Test("overlay scrollers follow scrollback") + func overlayStyleDoesNotReserveGutter() { + let policy = TerminalScrollBarPresencePolicy() + + #expect(!policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: false)) + #expect(policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: nil)) + #expect(policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: true)) + } + + @Test("disabled settings always hide the scrollbar") + func settingsOverrideStyle() { + let policy = TerminalScrollBarPresencePolicy() + + #expect(!policy.isPresent(allowedBySettings: false, scrollerStyle: .legacy, hasScrollback: true)) + #expect(!policy.isPresent(allowedBySettings: false, scrollerStyle: .overlay, hasScrollback: true)) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c01acf86cbff..829da2a9d6e0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -13080,10 +13080,8 @@ final class GhosttySurfaceScrollView: NSView { scrollView.hasVerticalScroller != shouldShowScrollBar || scrollView.autohidesScrollers scrollView.hasVerticalScroller = shouldShowScrollBar - // Keep the scroller visible whenever terminal scrollback exists. The - // scroller style itself is intentionally left to AppKit, which follows - // the user's Appearance > Show scroll bars preference and updates this - // scroll view when NSScroller.preferredScrollerStyle changes. + // AppKit owns the style (Show scroll bars preference); the policy owns + // presence so legacy gutters never depend on terminal scrollback. scrollView.autohidesScrollers = false updateTrackingAreas() return didChange @@ -13160,14 +13158,11 @@ final class GhosttySurfaceScrollView: NSView { } private func shouldShowTerminalScrollBar() -> Bool { - guard terminalScrollBarAllowedBySettings() else { return false } - guard let hasScrollback = surfaceHasScrollback() else { - // Ghostty reports scrollback asynchronously. Until the first packet - // arrives, keep the scroller visible so restored/reattached - // surfaces with existing scrollback do not appear broken. - return true - } - return hasScrollback + TerminalScrollBarPresencePolicy().isPresent( + allowedBySettings: terminalScrollBarAllowedBySettings(), + scrollerStyle: scrollView.scrollerStyle == .legacy ? .legacy : .overlay, + hasScrollback: surfaceHasScrollback() + ) } } diff --git a/Sources/TerminalScrollBarPresencePolicy.swift b/Sources/TerminalScrollBarPresencePolicy.swift deleted file mode 100644 index 1e3647166679..000000000000 --- a/Sources/TerminalScrollBarPresencePolicy.swift +++ /dev/null @@ -1,39 +0,0 @@ -import AppKit - -/// Decides whether a terminal pane's vertical scroller is present. -/// -/// Presence is a layout input, not only a visual: with the legacy scroller -/// style AppKit reserves a gutter for a present scroller, and the terminal -/// grid loses the columns underneath it. Under that style presence must not -/// follow the surface's own scrollback, or the grid becomes a function of the -/// terminal's content. A local pane then reflows when its first row scrolls -/// off (https://github.com/manaflow-ai/cmux/issues/3051), and a Cloud mirror, -/// whose reset empties history before every remote replay refills it, reports -/// a new grid after each replay; the remote PTY resizes again and sends the -/// next replay, and the loop never ends -/// (https://github.com/manaflow-ai/cmux/issues/12885). -/// -/// An overlay scroller reserves nothing, so it stays hidden while nothing can -/// scroll and never sits on top of the rightmost column of a full-screen app. -enum TerminalScrollBarPresencePolicy { - /// - Parameters: - /// - allowedBySettings: Whether the Ghostty `scrollbar` config and the - /// cmux scroll bar preference permit a scroller at all. - /// - scrollerStyle: The style the scroll view lays out with. - /// - hasScrollback: Whether the surface has rows above its viewport, or - /// nil while Ghostty has not published its first scrollbar state. - static func isPresent( - allowedBySettings: Bool, - scrollerStyle: NSScroller.Style, - hasScrollback: Bool? - ) -> Bool { - guard allowedBySettings else { return false } - // A legacy scroller is part of the layout; keep it so the grid width - // is the same with and without history. - if scrollerStyle == .legacy { return true } - // Ghostty reports scrollback asynchronously. Until the first packet - // arrives, keep the scroller so restored or reattached surfaces with - // existing scrollback do not appear broken. - return hasScrollback ?? true - } -} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c555b30365bc..da0f36a6bd52 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3425,7 +3425,6 @@ 126570000000000000000004 /* TerminalPortalTestWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */; }; D0F101000000000000000003 /* TerminalRawMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F101000000000000000013 /* TerminalRawMode.swift */; }; C11322A10000000000012885 /* TerminalScrollBarGutterStabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322A20000000000012885 /* TerminalScrollBarGutterStabilityTests.swift */; }; - 816400000000000000012886 /* TerminalScrollBarPresencePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816400000000000000012885 /* TerminalScrollBarPresencePolicy.swift */; }; D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */; }; D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */; }; 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */; }; @@ -7256,7 +7255,6 @@ 126570000000000000000003 /* TerminalPortalTestWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalPortalTestWorkspace.swift"; sourceTree = ""; }; D0F101000000000000000013 /* TerminalRawMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRawMode.swift; sourceTree = ""; }; C11322A20000000000012885 /* TerminalScrollBarGutterStabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollBarGutterStabilityTests.swift; sourceTree = ""; }; - 816400000000000000012885 /* TerminalScrollBarPresencePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollBarPresencePolicy.swift; sourceTree = ""; }; D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalScrollSpeedSettings.swift; sourceTree = ""; }; D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollSpeedSettingsFileStoreTests.swift; sourceTree = ""; }; E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/TerminalSearchNavigation.swift; sourceTree = ""; }; @@ -9240,7 +9238,6 @@ E89950020000000000000002 /* KeyboardCopyModeSelectionKind.swift */, 85510002A1B2C3D4E5F60001 /* GhosttyApp+ChildExitPolicy.swift */, 816400000000000000000002 /* GhosttyScrollView.swift */, - 816400000000000000012885 /* TerminalScrollBarPresencePolicy.swift */, C85390010000000000000002 /* GhosttyStartupAppearancePreviewProfile.swift */, A11E5E1E0000000000000002 /* TerminalSelectionAccessibilityNotifier.swift */, A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */, @@ -14472,7 +14469,6 @@ 2681A216F02BEE5AD12CE00D /* TerminalPastePreparationWorkerTextPayload.swift in Sources */, F87910110000000000000001 /* TerminalPendingConfigurationReload.swift in Sources */, 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */, - 816400000000000000012886 /* TerminalScrollBarPresencePolicy.swift in Sources */, D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */, 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */, C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */, diff --git a/cmuxTests/TerminalScrollBarGutterStabilityTests.swift b/cmuxTests/TerminalScrollBarGutterStabilityTests.swift index 181fdd133adb..8fae5615d78f 100644 --- a/cmuxTests/TerminalScrollBarGutterStabilityTests.swift +++ b/cmuxTests/TerminalScrollBarGutterStabilityTests.swift @@ -92,15 +92,4 @@ struct TerminalScrollBarGutterStabilityTests { #expect(afterReset == harness.paneWidth) } - @Test("Presence follows scrollback only where it reserves no layout") - func presencePolicy() { - typealias Policy = TerminalScrollBarPresencePolicy - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: false)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: nil)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .legacy, hasScrollback: true)) - #expect(!Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: false)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: true)) - #expect(Policy.isPresent(allowedBySettings: true, scrollerStyle: .overlay, hasScrollback: nil)) - #expect(!Policy.isPresent(allowedBySettings: false, scrollerStyle: .legacy, hasScrollback: true)) - } } From 550311df09aae3b8c05aa0a9a53c5a9f3f5cd5bb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:17:02 -0700 Subject: [PATCH 3/3] Handle optional capability probe in browser proxy --- Sources/Cloud/CloudMachineLinkManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudMachineLinkManager.swift b/Sources/Cloud/CloudMachineLinkManager.swift index 617d92f034b9..0ed6726c9931 100644 --- a/Sources/Cloud/CloudMachineLinkManager.swift +++ b/Sources/Cloud/CloudMachineLinkManager.swift @@ -331,7 +331,7 @@ actor CloudMachineLinkManager { return try await browserProxy(machineID: machineID) } guard let clientURL, let hub else { throw ManagerError.wireGuardHubMissing } - guard Self.clientCapabilities(clientURL: clientURL).contains("browser-proxy") else { + guard Self.clientCapabilities(clientURL: clientURL)?.contains("browser-proxy") == true else { throw ManagerError.retryLater(String(localized: "cloud.browser.clientUpdateRequired", defaultValue: "Update cmux to connect to this Cloud page.")) } let proxy = CloudBrowserProxyProcess(addresses: addresses) @@ -350,7 +350,7 @@ actor CloudMachineLinkManager { let endpoint = try await client.openCmuxRemote( id: machineID, deviceFingerprint: nil, - clientCapabilities: Self.clientCapabilities(clientURL: clientURL) + clientCapabilities: Self.clientCapabilities(clientURL: clientURL) ?? [] ) guard endpoint.trustedCarrier else { throw ManagerError.retryLater(String(