From 9abf20c481125903255dcb23051a32c0e32e2e3a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Apr 2026 14:03:27 -0700 Subject: [PATCH 01/15] Suppress terminal scrollbar for TUI workspaces --- Resources/Localizable.xcstrings | 102 ++++++++++++++++++ Sources/ContentView.swift | 29 +++++ Sources/GhosttyTerminalView.swift | 93 +++++++++++++--- .../KeyboardShortcutSettingsFileStore.swift | 21 ++++ Sources/SessionPersistence.swift | 1 + Sources/TabManager.swift | 6 ++ Sources/Workspace.swift | 10 ++ Sources/cmuxApp.swift | 34 ++++++ web/app/[locale]/docs/configuration/page.tsx | 9 +- web/data/cmux-settings.schema.json | 13 +++ 10 files changed, 303 insertions(+), 15 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7f9c74cc0b0f..1f4e9c4b73e1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -87391,6 +87391,108 @@ } } } + }, + "contextMenu.workspaceSettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace Settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース設定" + } + } + } + }, + "contextMenu.workspaceSettings.hideTerminalScrollBar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Terminal Scroll Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのスクロールバーを隠す" + } + } + } + }, + "settings.section.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "settings.terminal.scrollBar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Terminal Scroll Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのスクロールバーを表示" + } + } + } + }, + "settings.terminal.scrollBar.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hides the right-edge terminal scroll bar everywhere. Changes apply immediately and persist across relaunches." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右端のターミナルスクロールバーを全体で非表示にします。変更はすぐに反映され、再起動後も保持されます。" + } + } + } + }, + "settings.terminal.scrollBar.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows the right-edge terminal scroll bar in shell scrollback. cmux hides it automatically for alternate-screen style TUI surfaces and you can also disable it per workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シェルのスクロールバックでは右端のターミナルスクロールバーを表示します。cmux は代替画面系の TUI では自動的に隠し、ワークスペースごとにも無効化できます。" + } + } + } } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index da5491e65839..f3e312212add 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13275,6 +13275,20 @@ private struct TabItemView: View, Equatable { .disabled(allRemoteContextMenuTargetsDisconnected) } + Menu(String(localized: "contextMenu.workspaceSettings", defaultValue: "Workspace Settings")) { + Button { + toggleWorkspaceTerminalScrollBarHidden(targetIds: targetIds) + } label: { + Label { + Text(String(localized: "contextMenu.workspaceSettings.hideTerminalScrollBar", defaultValue: "Hide Terminal Scroll Bar")) + } icon: { + if allTargetWorkspacesHideTerminalScrollBar(in: targetIds) { + Image(systemName: "checkmark") + } + } + } + } + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { if tab.customColor != nil { Button { @@ -13978,6 +13992,21 @@ private struct TabItemView: View, Equatable { } } + private func allTargetWorkspacesHideTerminalScrollBar(in targetIds: [UUID]) -> Bool { + let workspaces = targetIds.compactMap { targetId in + tabManager.tabs.first(where: { $0.id == targetId }) + } + guard !workspaces.isEmpty else { return false } + return workspaces.allSatisfy { $0.terminalScrollBarHidden } + } + + private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { + let hideScrollBar = !allTargetWorkspacesHideTerminalScrollBar(in: targetIds) + for targetId in targetIds { + tabManager.setWorkspaceTerminalScrollBarHidden(tabId: targetId, hidden: hideScrollBar) + } + } + private func promptCustomColor(targetIds: [UUID]) { let alert = NSAlert() alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6f502b38c7dc..bf085fb26541 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8544,6 +8544,7 @@ final class GhosttySurfaceScrollView: NSView { private var activeImageTransferOperation: TerminalImageTransferOperation? private var activeImageTransferCancelHandler: (() -> Void)? private var lastSearchOverlayStateID: ObjectIdentifier? + private weak var cachedOwningWorkspace: Workspace? private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] @@ -9062,6 +9063,14 @@ final class GhosttySurfaceScrollView: NSView { self?.handlePreferredScrollerStyleChange() }) + observers.append(NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleTerminalScrollBarPreferenceChange() + }) + } required init?(coder: NSCoder) { @@ -9161,10 +9170,14 @@ final class GhosttySurfaceScrollView: NSView { CATransaction.setDisableActions(true) defer { CATransaction.commit() } + let didScrollbarAppearanceChange = synchronizeScrollbarAppearance() let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) _ = setFrameIfNeeded(scrollView, to: bounds) - let targetSize = scrollView.bounds.size + let targetSize = CGSize( + width: max(0, scrollView.bounds.width - terminalScrollBarReservedWidth()), + height: scrollView.bounds.height + ) #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) #endif @@ -9202,6 +9215,9 @@ final class GhosttySurfaceScrollView: NSView { } // NSScrollView can defer clip-view/content-size updates until its own layout pass, // which makes interactive width changes arrive a queue turn late on Sequoia. + if didScrollbarAppearanceChange { + scrollView.tile() + } scrollView.layoutSubtreeIfNeeded() updateNotificationRingPath() updateFlashPath(style: lastFlashStyle) @@ -11130,10 +11146,7 @@ final class GhosttySurfaceScrollView: NSView { /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult private func synchronizeCoreSurface() -> Bool { - // Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight - // over terminal columns during split churn. The width can flap by one scrollbar gutter, - // which redraws the shell prompt multiple times on Cmd+D. Favor stable columns. - let width = max(0, scrollView.contentSize.width) + let width = max(0, surfaceView.frame.width) let height = surfaceView.frame.height guard width > 0, height > 0 else { return false } return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) @@ -11265,23 +11278,36 @@ final class GhosttySurfaceScrollView: NSView { guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { return } + let wasVisible = shouldShowTerminalScrollBar() if pendingExplicitWheelScroll { userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total allowExplicitScrollbarSync = true pendingExplicitWheelScroll = false } surfaceView.scrollbar = scrollbar + let isVisible = shouldShowTerminalScrollBar() + if wasVisible != isVisible { + _ = synchronizeGeometryAndContent() + return + } synchronizeScrollView() } - private func synchronizeScrollbarAppearance() { - scrollView.hasVerticalScroller = GhosttyApp.shared.scrollbarVisibility() != .never + @discardableResult + private func synchronizeScrollbarAppearance() -> Bool { + let shouldShowScrollBar = shouldShowTerminalScrollBar() + let didChange = + scrollView.hasVerticalScroller != shouldShowScrollBar || + scrollView.autohidesScrollers != false || + scrollView.scrollerStyle != .overlay + scrollView.hasVerticalScroller = shouldShowScrollBar // Mirror upstream Ghostty: keep overlay scrollers even when the // system preference is legacy so terminal content never sits beneath a // permanently reserved scrollbar gutter. scrollView.autohidesScrollers = false scrollView.scrollerStyle = .overlay updateTrackingAreas() + return didChange } private func handlePreferredScrollerStyleChange() { @@ -11292,13 +11318,18 @@ final class GhosttySurfaceScrollView: NSView { return } - synchronizeScrollbarAppearance() + _ = synchronizeGeometryAndContent() + } - // Retile just the scroll view so contentSize reflects the current - // scrollbar mode without perturbing viewport origin or hosted view - // geometry; the broader reconcile path caused visible content glitches. - scrollView.tile() - _ = synchronizeCoreSurface() + private func handleTerminalScrollBarPreferenceChange() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.handleTerminalScrollBarPreferenceChange() + } + return + } + + _ = synchronizeGeometryAndContent() } private func documentHeight() -> CGFloat { @@ -11311,6 +11342,42 @@ final class GhosttySurfaceScrollView: NSView { } return contentHeight } + + private func owningWorkspace() -> Workspace? { + let workspaceId = surfaceView.terminalSurface?.tabId + if let cachedOwningWorkspace, + cachedOwningWorkspace.id == workspaceId { + return cachedOwningWorkspace + } + let workspace = surfaceView.terminalSurface?.owningWorkspace() + cachedOwningWorkspace = workspace + return workspace + } + + private func terminalScrollBarAllowedBySettings() -> Bool { + guard GhosttyApp.shared.scrollbarVisibility() != .never else { return false } + guard TerminalScrollBarSettings.isVisible() else { return false } + guard owningWorkspace()?.terminalScrollBarHidden != true else { return false } + return true + } + + private func surfaceHasScrollback() -> Bool { + guard let scrollbar = surfaceView.scrollbar else { return false } + // Embedded Ghostty exposes alternate-screen TUIs to the wrapper as a + // viewport with no additional scrollback (`total <= len`). Treat that + // as the signal to suppress the overlay scrollbar so full-screen apps + // like nvim/htop do not pin it on top of the rightmost cell column. + return scrollbar.total > scrollbar.len + } + + private func shouldShowTerminalScrollBar() -> Bool { + terminalScrollBarAllowedBySettings() && surfaceHasScrollback() + } + + private func terminalScrollBarReservedWidth() -> CGFloat { + guard shouldShowTerminalScrollBar() else { return 0 } + return ceil(NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay)) + } } // MARK: - NSTextInputClient diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 276754dbd1b0..ff9bc786080a 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -38,6 +38,7 @@ final class CmuxSettingsFileStore { "app.warnBeforeQuit", "app.renameSelectsExistingName", "app.commandPaletteSearchesAllSurfaces", + "terminal.showScrollBar", "notifications.dockBadge", "notifications.showInMenuBar", "notifications.unreadPaneRing", @@ -348,6 +349,9 @@ final class CmuxSettingsFileStore { if let appSection = root["app"] as? [String: Any] { parseAppSection(appSection, sourcePath: sourcePath, snapshot: &snapshot) } + if let terminalSection = root["terminal"] as? [String: Any] { + parseTerminalSection(terminalSection, sourcePath: sourcePath, snapshot: &snapshot) + } if let notificationsSection = root["notifications"] as? [String: Any] { parseNotificationsSection(notificationsSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -474,6 +478,18 @@ final class CmuxSettingsFileStore { } } + private func parseTerminalSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["showScrollBar"]) { + snapshot.managedUserDefaults[TerminalScrollBarSettings.showScrollBarKey] = .bool(value) + } else if section.keys.contains("showScrollBar") { + logInvalid("terminal.showScrollBar", sourcePath: sourcePath) + } + } + private func parseSidebarSection( _ section: [String: Any], sourcePath: String, @@ -1324,6 +1340,11 @@ final class CmuxSettingsFileStore { "commandPaletteSearchesAllSurfaces": CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces, ], ], + [ + "terminal": [ + "showScrollBar": TerminalScrollBarSettings.defaultShowScrollBar, + ], + ], [ "notifications": [ "dockBadge": NotificationBadgeSettings.defaultDockBadgeEnabled, diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index a6498b8bec1c..5c1bfbb1733c 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -333,6 +333,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var customDescription: String? var customColor: String? var isPinned: Bool + var terminalScrollBarHidden: Bool? var currentDirectory: String var focusedPanelId: UUID? var layout: SessionWorkspaceLayoutSnapshot diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 9e9a94378d9a..f4adc10fabcd 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3561,6 +3561,11 @@ class TabManager: ObservableObject { tab.setCustomColor(color) } + func setWorkspaceTerminalScrollBarHidden(tabId: UUID, hidden: Bool) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setTerminalScrollBarHidden(hidden) + } + func togglePin(tabId: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } let tab = tabs[index] @@ -6730,6 +6735,7 @@ extension TabManager { hasher.combine(workspace.customDescription ?? "") hasher.combine(workspace.customColor ?? "") hasher.combine(workspace.isPinned) + hasher.combine(workspace.terminalScrollBarHidden) hasher.combine(workspace.panels.count) hasher.combine(workspace.statusEntries.count) hasher.combine(workspace.metadataBlocks.count) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index dac4b68fca8c..f4cd093e29b6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -298,6 +298,7 @@ extension Workspace { customDescription: customDescription, customColor: customColor, isPinned: isPinned, + terminalScrollBarHidden: terminalScrollBarHidden ? true : nil, currentDirectory: currentDirectory, focusedPanelId: focusedPanelId, layout: layout, @@ -338,6 +339,7 @@ extension Workspace { setCustomDescription(snapshot.customDescription) setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned + setTerminalScrollBarHidden(snapshot.terminalScrollBarHidden ?? false) // Status entries and agent PIDs are ephemeral runtime state tied to running // processes (e.g. claude_code "Running"). Don't restore them across app @@ -6486,6 +6488,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var customDescription: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" + @Published var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String private(set) var preferredBrowserProfileID: UUID? @@ -6630,6 +6633,7 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($customDescription), sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), + sidebarObservationSignal($terminalScrollBarHidden), ] return Publishers.MergeMany(publishers).eraseToAnyPublisher() @@ -7522,6 +7526,12 @@ final class Workspace: Identifiable, ObservableObject { } } + func setTerminalScrollBarHidden(_ hidden: Bool) { + guard terminalScrollBarHidden != hidden else { return } + terminalScrollBarHidden = hidden + scheduleTerminalGeometryReconcile() + } + private static func normalizedCustomDescription(_ description: String?) -> String? { let normalizedLineEndings = description? .replacingOccurrences(of: "\r\n", with: "\n") diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 8ca1b26cfd58..1578d2272de9 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -96,6 +96,18 @@ enum PaneFirstClickFocusSettings { } } +enum TerminalScrollBarSettings { + static let showScrollBarKey = "terminal.showScrollBar" + static let defaultShowScrollBar = true + + static func isVisible(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showScrollBarKey) == nil { + return defaultShowScrollBar + } + return defaults.bool(forKey: showScrollBarKey) + } +} + enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" @@ -4102,6 +4114,8 @@ struct SettingsView: View { private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue @AppStorage(PaneFirstClickFocusSettings.enabledKey) private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + @AppStorage(TerminalScrollBarSettings.showScrollBarKey) + private var showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -5156,6 +5170,25 @@ struct SettingsView: View { .disabled(sidebarHideAllDetails) } + SettingsSectionHeader(title: String(localized: "settings.section.terminal", defaultValue: "Terminal")) + SettingsCard { + SettingsCardRow( + configurationReview: .json("terminal.showScrollBar"), + String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar"), + subtitle: showTerminalScrollBar + ? String(localized: "settings.terminal.scrollBar.subtitleOn", defaultValue: "Shows the right-edge terminal scroll bar in shell scrollback. cmux hides it automatically for alternate-screen style TUI surfaces and you can also disable it per workspace.") + : String(localized: "settings.terminal.scrollBar.subtitleOff", defaultValue: "Hides the right-edge terminal scroll bar everywhere. Changes apply immediately and persist across relaunches.") + ) { + Toggle("", isOn: $showTerminalScrollBar) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsTerminalScrollBarToggle") + .accessibilityLabel( + String(localized: "settings.terminal.scrollBar", defaultValue: "Show Terminal Scroll Bar") + ) + } + } + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) SettingsCard { SettingsPickerRow( @@ -6148,6 +6181,7 @@ struct SettingsView: View { defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index deef6ee9e491..19b518d8e479 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -35,6 +35,7 @@ const schemaSourceUrl = "https://github.com/manaflow-ai/cmux/blob/main/web/data/cmux-settings.schema.json"; const sectionOrder = [ "app", + "terminal", "notifications", "sidebar", "workspaceColors", @@ -54,6 +55,10 @@ const settingsFileExample = `{ // "newWorkspacePlacement": "afterCurrent" // }, + // "terminal": { + // "showScrollBar": false + // }, + // "browser": { // "openTerminalLinksInCmuxBrowser": true, // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] @@ -286,8 +291,8 @@ working-directory = ~/code`}

Schema reference

This reference covers every supported key in settings.json. The embedded - browser, sidebar, notifications, automation, and cmux-owned keyboard shortcuts all live - here. + browser, terminal, sidebar, notifications, automation, and cmux-owned keyboard shortcuts + all live here.

Metadata

diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index c5bc5e915b2f..5d883528804e 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -95,6 +95,19 @@ } } }, + "terminal": { + "title": "terminal", + "description": "Terminal presentation settings from Settings > Terminal.", + "type": "object", + "additionalProperties": false, + "properties": { + "showScrollBar": { + "type": "boolean", + "default": true, + "description": "Show the right-edge terminal scroll bar when scrollback is available. cmux automatically suppresses it for alternate-screen style TUI surfaces." + } + } + }, "notifications": { "title": "notifications", "description": "Notification behavior from Settings > Notifications.", From 7cbd2b82f57f40cd7eaea7a300db35a5b504b067 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Apr 2026 16:31:41 -0700 Subject: [PATCH 02/15] Keep terminal scrollbar gutter width stable --- Sources/ContentView.swift | 27 ++++++++++--------- Sources/GhosttyTerminalView.swift | 17 ++++++++---- .../KeyboardShortcutSettingsFileStore.swift | 16 +++++++++++ Sources/Workspace.swift | 3 +-- Sources/cmuxApp.swift | 18 ++++++++++++- 5 files changed, 61 insertions(+), 20 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f3e312212add..eb946cad260e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9940,6 +9940,7 @@ struct VerticalTabsSidebar: View { let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) + let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let orderedSelectedTabs = tabs.filter { selectedTabIds.contains($0.id) } let selectedContextTargetIds = orderedSelectedTabs.map(\.id) let selectedRemoteContextMenuTargets = orderedSelectedTabs.filter { $0.isRemoteWorkspace } @@ -9969,6 +9970,10 @@ struct VerticalTabsSidebar: View { let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedRemoteContextMenuWorkspaceIds : (tab.isRemoteWorkspace ? [tab.id] : []) + let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && + contextMenuWorkspaceIds.allSatisfy { workspaceId in + tabsById[workspaceId]?.terminalScrollBarHidden == true + } let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets ? allSelectedRemoteContextMenuTargetsConnecting : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) @@ -10019,6 +10024,7 @@ struct VerticalTabsSidebar: View { draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, contextMenuWorkspaceIds: contextMenuWorkspaceIds, + allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, @@ -12427,6 +12433,7 @@ private struct TabItemView: View, Equatable { lhs.rowSpacing == rhs.rowSpacing && lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && + lhs.allContextMenuWorkspacesHideTerminalScrollBar == rhs.allContextMenuWorkspacesHideTerminalScrollBar && lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && @@ -12457,6 +12464,7 @@ private struct TabItemView: View, Equatable { @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? let contextMenuWorkspaceIds: [UUID] + let allContextMenuWorkspacesHideTerminalScrollBar: Bool let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool @@ -13277,12 +13285,15 @@ private struct TabItemView: View, Equatable { Menu(String(localized: "contextMenu.workspaceSettings", defaultValue: "Workspace Settings")) { Button { - toggleWorkspaceTerminalScrollBarHidden(targetIds: targetIds) + toggleWorkspaceTerminalScrollBarHidden( + targetIds: targetIds, + currentlyHidden: allContextMenuWorkspacesHideTerminalScrollBar + ) } label: { Label { Text(String(localized: "contextMenu.workspaceSettings.hideTerminalScrollBar", defaultValue: "Hide Terminal Scroll Bar")) } icon: { - if allTargetWorkspacesHideTerminalScrollBar(in: targetIds) { + if allContextMenuWorkspacesHideTerminalScrollBar { Image(systemName: "checkmark") } } @@ -13992,16 +14003,8 @@ private struct TabItemView: View, Equatable { } } - private func allTargetWorkspacesHideTerminalScrollBar(in targetIds: [UUID]) -> Bool { - let workspaces = targetIds.compactMap { targetId in - tabManager.tabs.first(where: { $0.id == targetId }) - } - guard !workspaces.isEmpty else { return false } - return workspaces.allSatisfy { $0.terminalScrollBarHidden } - } - - private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { - let hideScrollBar = !allTargetWorkspacesHideTerminalScrollBar(in: targetIds) + private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID], currentlyHidden: Bool) { + let hideScrollBar = !currentlyHidden for targetId in targetIds { tabManager.setWorkspaceTerminalScrollBarHidden(tabId: targetId, hidden: hideScrollBar) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index bf085fb26541..0ccb7cfedc42 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9064,7 +9064,7 @@ final class GhosttySurfaceScrollView: NSView { }) observers.append(NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, + forName: TerminalScrollBarSettings.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in @@ -11278,14 +11278,14 @@ final class GhosttySurfaceScrollView: NSView { guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { return } - let wasVisible = shouldShowTerminalScrollBar() + let wasVisible = scrollView.hasVerticalScroller if pendingExplicitWheelScroll { userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total allowExplicitScrollbarSync = true pendingExplicitWheelScroll = false } surfaceView.scrollbar = scrollbar - let isVisible = shouldShowTerminalScrollBar() + let isVisible = terminalScrollBarAllowedBySettings() && scrollbar.total > scrollbar.len if wasVisible != isVisible { _ = synchronizeGeometryAndContent() return @@ -11318,7 +11318,12 @@ final class GhosttySurfaceScrollView: NSView { return } - _ = synchronizeGeometryAndContent() + synchronizeScrollbarAppearance() + + // Retile just the scroll view so contentSize reflects the current + // scroller preference without perturbing hosted terminal geometry. + scrollView.tile() + _ = synchronizeCoreSurface() } private func handleTerminalScrollBarPreferenceChange() { @@ -11375,7 +11380,9 @@ final class GhosttySurfaceScrollView: NSView { } private func terminalScrollBarReservedWidth() -> CGFloat { - guard shouldShowTerminalScrollBar() else { return 0 } + // Keep the PTY width stable while scrollback appears/disappears so + // entering alternate-screen TUIs does not reflow the surface. + guard terminalScrollBarAllowedBySettings() else { return 0 } return ceil(NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay)) } } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index ff9bc786080a..97c0499b6731 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -1117,26 +1117,31 @@ final class CmuxSettingsFileStore { return } + var didMutateStoredValue = false switch value { case .bool(let next): let current = defaults.object(forKey: defaultsKey) as? Bool if current != next { defaults.set(next, forKey: defaultsKey) + didMutateStoredValue = true } case .int(let next): let current = defaults.object(forKey: defaultsKey) as? Int if current != next { defaults.set(next, forKey: defaultsKey) + didMutateStoredValue = true } case .double(let next): let current = defaults.object(forKey: defaultsKey) as? Double if current != next { defaults.set(next, forKey: defaultsKey) + didMutateStoredValue = true } case .string(let next): let current = defaults.string(forKey: defaultsKey) if current != next { defaults.set(next, forKey: defaultsKey) + didMutateStoredValue = true } case .nullableString(let next): let current = defaults.string(forKey: defaultsKey) @@ -1146,19 +1151,26 @@ final class CmuxSettingsFileStore { } else { defaults.removeObject(forKey: defaultsKey) } + didMutateStoredValue = true } case .stringArray(let next): let current = defaults.array(forKey: defaultsKey) as? [String] if current != next { defaults.set(next, forKey: defaultsKey) + didMutateStoredValue = true } case .stringDictionary(let next): let current = defaults.dictionary(forKey: defaultsKey) as? [String: String] if current != next { defaults.set(next, forKey: defaultsKey) + didMutateStoredValue = true } } + if defaultsKey == TerminalScrollBarSettings.showScrollBarKey, didMutateStoredValue { + TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) + } + switch defaultsKey { case LanguageSettings.languageKey: let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system @@ -1201,6 +1213,10 @@ final class CmuxSettingsFileStore { defaults.set(value, forKey: defaultsKey) } + if defaultsKey == TerminalScrollBarSettings.showScrollBarKey { + TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) + } + switch defaultsKey { case LanguageSettings.languageKey: let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f4cd093e29b6..4ca46ec9f03c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6488,7 +6488,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var customDescription: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" - @Published var terminalScrollBarHidden: Bool = false + @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String private(set) var preferredBrowserProfileID: UUID? @@ -6633,7 +6633,6 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($customDescription), sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), - sidebarObservationSignal($terminalScrollBarHidden), ] return Publishers.MergeMany(publishers).eraseToAnyPublisher() diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 1578d2272de9..d485c093999b 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -99,6 +99,7 @@ enum PaneFirstClickFocusSettings { enum TerminalScrollBarSettings { static let showScrollBarKey = "terminal.showScrollBar" static let defaultShowScrollBar = true + static let didChangeNotification = Notification.Name("cmux.terminalScrollBarSettingsDidChange") static func isVisible(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: showScrollBarKey) == nil { @@ -106,6 +107,10 @@ enum TerminalScrollBarSettings { } return defaults.bool(forKey: showScrollBarKey) } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } } enum UITestLaunchManifest { @@ -4228,6 +4233,17 @@ struct SettingsView: View { ) } + private var showTerminalScrollBarBinding: Binding { + Binding( + get: { showTerminalScrollBar }, + set: { newValue in + guard showTerminalScrollBar != newValue else { return } + showTerminalScrollBar = newValue + TerminalScrollBarSettings.notifyDidChange() + } + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -5179,7 +5195,7 @@ struct SettingsView: View { ? String(localized: "settings.terminal.scrollBar.subtitleOn", defaultValue: "Shows the right-edge terminal scroll bar in shell scrollback. cmux hides it automatically for alternate-screen style TUI surfaces and you can also disable it per workspace.") : String(localized: "settings.terminal.scrollBar.subtitleOff", defaultValue: "Hides the right-edge terminal scroll bar everywhere. Changes apply immediately and persist across relaunches.") ) { - Toggle("", isOn: $showTerminalScrollBar) + Toggle("", isOn: showTerminalScrollBarBinding) .labelsHidden() .controlSize(.small) .accessibilityIdentifier("SettingsTerminalScrollBarToggle") From 85cf4140ed7e4e47fbf592c36a74d84f3fa92204 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Apr 2026 17:15:15 -0700 Subject: [PATCH 03/15] Address scrollbar review follow-ups --- Sources/ContentView.swift | 11 ++++++----- Sources/GhosttyTerminalView.swift | 33 +++++++++++++++++++++++++++++++ Sources/Workspace.swift | 8 ++++++++ Sources/cmuxApp.swift | 4 ++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index eb946cad260e..4b7391a5de07 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13285,10 +13285,7 @@ private struct TabItemView: View, Equatable { Menu(String(localized: "contextMenu.workspaceSettings", defaultValue: "Workspace Settings")) { Button { - toggleWorkspaceTerminalScrollBarHidden( - targetIds: targetIds, - currentlyHidden: allContextMenuWorkspacesHideTerminalScrollBar - ) + toggleWorkspaceTerminalScrollBarHidden(targetIds: targetIds) } label: { Label { Text(String(localized: "contextMenu.workspaceSettings.hideTerminalScrollBar", defaultValue: "Hide Terminal Scroll Bar")) @@ -14003,7 +14000,11 @@ private struct TabItemView: View, Equatable { } } - private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID], currentlyHidden: Bool) { + private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { + let workspacesById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) + let currentlyHidden = !targetIds.isEmpty && targetIds.allSatisfy { targetId in + workspacesById[targetId]?.terminalScrollBarHidden == true + } let hideScrollBar = !currentlyHidden for targetId in targetIds { tabManager.setWorkspaceTerminalScrollBarHidden(tabId: targetId, hidden: hideScrollBar) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0ccb7cfedc42..a7aced1d2342 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8545,8 +8545,10 @@ final class GhosttySurfaceScrollView: NSView { private var activeImageTransferCancelHandler: (() -> Void)? private var lastSearchOverlayStateID: ObjectIdentifier? private weak var cachedOwningWorkspace: Workspace? + private weak var observedWorkspaceTerminalScrollBar: Workspace? private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] + private var workspaceTerminalScrollBarObserver: NSObjectProtocol? private var windowObservers: [NSObjectProtocol] = [] private var scrollbarTrackingArea: NSTrackingArea? private var isLiveScrolling = false @@ -9086,6 +9088,9 @@ final class GhosttySurfaceScrollView: NSView { ) #endif observers.forEach { NotificationCenter.default.removeObserver($0) } + if let workspaceTerminalScrollBarObserver { + NotificationCenter.default.removeObserver(workspaceTerminalScrollBarObserver) + } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } deferredSearchOverlayMutationWorkItem?.cancel() imageTransferIndicatorShowWorkItem?.cancel() @@ -9395,6 +9400,9 @@ final class GhosttySurfaceScrollView: NSView { func attachSurface(_ terminalSurface: TerminalSurface) { surfaceView.attachSurface(terminalSurface) + let workspace = terminalSurface.owningWorkspace() + cachedOwningWorkspace = workspace + updateWorkspaceTerminalScrollBarObserver(workspace) } func setFocusHandler(_ handler: (() -> Void)?) { @@ -11337,6 +11345,29 @@ final class GhosttySurfaceScrollView: NSView { _ = synchronizeGeometryAndContent() } + private func updateWorkspaceTerminalScrollBarObserver(_ workspace: Workspace?) { + guard observedWorkspaceTerminalScrollBar !== workspace || workspaceTerminalScrollBarObserver == nil else { + return + } + + if let workspaceTerminalScrollBarObserver { + NotificationCenter.default.removeObserver(workspaceTerminalScrollBarObserver) + self.workspaceTerminalScrollBarObserver = nil + } + + observedWorkspaceTerminalScrollBar = workspace + + guard let workspace else { return } + + workspaceTerminalScrollBarObserver = NotificationCenter.default.addObserver( + forName: Workspace.terminalScrollBarHiddenDidChangeNotification, + object: workspace, + queue: .main + ) { [weak self] _ in + self?.handleTerminalScrollBarPreferenceChange() + } + } + private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height @@ -11352,10 +11383,12 @@ final class GhosttySurfaceScrollView: NSView { let workspaceId = surfaceView.terminalSurface?.tabId if let cachedOwningWorkspace, cachedOwningWorkspace.id == workspaceId { + updateWorkspaceTerminalScrollBarObserver(cachedOwningWorkspace) return cachedOwningWorkspace } let workspace = surfaceView.terminalSurface?.owningWorkspace() cachedOwningWorkspace = workspace + updateWorkspaceTerminalScrollBarObserver(workspace) return workspace } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4ca46ec9f03c..46378364fec9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6482,6 +6482,10 @@ struct ClosedBrowserPanelRestoreSnapshot { /// Each workspace contains one BonsplitController that manages split panes and nested surfaces. @MainActor final class Workspace: Identifiable, ObservableObject { + static let terminalScrollBarHiddenDidChangeNotification = Notification.Name( + "cmux.workspaceTerminalScrollBarHiddenDidChange" + ) + let id: UUID @Published var title: String @Published var customTitle: String? @@ -7528,6 +7532,10 @@ final class Workspace: Identifiable, ObservableObject { func setTerminalScrollBarHidden(_ hidden: Bool) { guard terminalScrollBarHidden != hidden else { return } terminalScrollBarHidden = hidden + NotificationCenter.default.post( + name: Self.terminalScrollBarHiddenDidChangeNotification, + object: self + ) scheduleTerminalGeometryReconcile() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d485c093999b..8cb72aed4fe2 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -6197,7 +6197,11 @@ struct SettingsView: View { defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled + let previousShowTerminalScrollBar = showTerminalScrollBar showTerminalScrollBar = TerminalScrollBarSettings.defaultShowScrollBar + if previousShowTerminalScrollBar != showTerminalScrollBar { + TerminalScrollBarSettings.notifyDidChange() + } workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage From db18978e49dae149196ae75e255778e271618c8e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 8 Apr 2026 18:35:45 -0700 Subject: [PATCH 04/15] Address scrollbar follow-up review comments --- Sources/AppDelegate.swift | 1 - Sources/GhosttyTerminalView.swift | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d8239..b0ef3a4587f5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5810,7 +5810,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var refreshedCount = 0 forEachTerminalPanel { terminalPanel in terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() - terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") refreshedCount += 1 } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a7aced1d2342..4795c9a181ac 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9403,6 +9403,7 @@ final class GhosttySurfaceScrollView: NSView { let workspace = terminalSurface.owningWorkspace() cachedOwningWorkspace = workspace updateWorkspaceTerminalScrollBarObserver(workspace) + _ = synchronizeGeometryAndContent() } func setFocusHandler(_ handler: (() -> Void)?) { @@ -9841,7 +9842,7 @@ final class GhosttySurfaceScrollView: NSView { } func refreshHostBackgroundAfterGhosttyConfigReload() { - synchronizeScrollbarAppearance() + _ = synchronizeGeometryAndContent() surfaceView.applySurfaceBackground() surfaceView.applyWindowBackgroundIfActive() } From 83739b497064578bcf12832fe57e1d1aad23c55b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 9 Apr 2026 17:12:01 -0700 Subject: [PATCH 05/15] Address scrollbar review feedback --- Sources/ContentView.swift | 18 +++++++++--------- Sources/GhosttyTerminalView.swift | 2 ++ Sources/Workspace.swift | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4b7391a5de07..c87351a419a8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9940,7 +9940,6 @@ struct VerticalTabsSidebar: View { let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) - let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let orderedSelectedTabs = tabs.filter { selectedTabIds.contains($0.id) } let selectedContextTargetIds = orderedSelectedTabs.map(\.id) let selectedRemoteContextMenuTargets = orderedSelectedTabs.filter { $0.isRemoteWorkspace } @@ -9970,10 +9969,6 @@ struct VerticalTabsSidebar: View { let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedRemoteContextMenuWorkspaceIds : (tab.isRemoteWorkspace ? [tab.id] : []) - let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && - contextMenuWorkspaceIds.allSatisfy { workspaceId in - tabsById[workspaceId]?.terminalScrollBarHidden == true - } let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets ? allSelectedRemoteContextMenuTargetsConnecting : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) @@ -10024,7 +10019,6 @@ struct VerticalTabsSidebar: View { draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, contextMenuWorkspaceIds: contextMenuWorkspaceIds, - allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, @@ -12433,7 +12427,6 @@ private struct TabItemView: View, Equatable { lhs.rowSpacing == rhs.rowSpacing && lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.allContextMenuWorkspacesHideTerminalScrollBar == rhs.allContextMenuWorkspacesHideTerminalScrollBar && lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && @@ -12464,7 +12457,6 @@ private struct TabItemView: View, Equatable { @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? let contextMenuWorkspaceIds: [UUID] - let allContextMenuWorkspacesHideTerminalScrollBar: Bool let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool @@ -13178,11 +13170,19 @@ private struct TabItemView: View, Equatable { } } + private func allTargetWorkspacesHideTerminalScrollBar(_ targetIds: [UUID]) -> Bool { + guard !targetIds.isEmpty else { return false } + return targetIds.allSatisfy { targetId in + tabManager.tabs.first(where: { $0.id == targetId })?.terminalScrollBarHidden == true + } + } + @ViewBuilder private var workspaceContextMenu: some View { let targetIds = contextMenuWorkspaceIds let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() + let hidesTerminalScrollBarForAllTargets = allTargetWorkspacesHideTerminalScrollBar(targetIds) let shouldPin = !tab.isPinned let reconnectLabel = contextMenuLabel( multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), @@ -13290,7 +13290,7 @@ private struct TabItemView: View, Equatable { Label { Text(String(localized: "contextMenu.workspaceSettings.hideTerminalScrollBar", defaultValue: "Hide Terminal Scroll Bar")) } icon: { - if allContextMenuWorkspacesHideTerminalScrollBar { + if hidesTerminalScrollBarForAllTargets { Image(systemName: "checkmark") } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4795c9a181ac..bc9609918cf6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9403,6 +9403,8 @@ final class GhosttySurfaceScrollView: NSView { let workspace = terminalSurface.owningWorkspace() cachedOwningWorkspace = workspace updateWorkspaceTerminalScrollBarObserver(workspace) + // Preserve the bootstrap 800x600 surface until the host has real bounds. + guard bounds.width > 0, bounds.height > 0 else { return } _ = synchronizeGeometryAndContent() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 46378364fec9..a91f031f3420 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6637,6 +6637,7 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($customDescription), sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), + sidebarObservationSignal($terminalScrollBarHidden), ] return Publishers.MergeMany(publishers).eraseToAnyPublisher() From 765b9eaeb12ddeb4e2c213a219806ce106824394 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 9 Apr 2026 17:34:02 -0700 Subject: [PATCH 06/15] Address minor PR review comments --- Sources/ContentView.swift | 17 ++++++++--------- Sources/GhosttyTerminalView.swift | 4 +++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c87351a419a8..c65dc9735e8c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9966,6 +9966,10 @@ struct VerticalTabsSidebar: View { let contextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedContextTargetIds : [tab.id] + let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && + contextMenuWorkspaceIds.allSatisfy { workspaceId in + tabsById[workspaceId]?.terminalScrollBarHidden == true + } let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedRemoteContextMenuWorkspaceIds : (tab.isRemoteWorkspace ? [tab.id] : []) @@ -10019,6 +10023,7 @@ struct VerticalTabsSidebar: View { draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, contextMenuWorkspaceIds: contextMenuWorkspaceIds, + allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, @@ -12427,6 +12432,7 @@ private struct TabItemView: View, Equatable { lhs.rowSpacing == rhs.rowSpacing && lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && + lhs.allContextMenuWorkspacesHideTerminalScrollBar == rhs.allContextMenuWorkspacesHideTerminalScrollBar && lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && @@ -12457,6 +12463,7 @@ private struct TabItemView: View, Equatable { @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? let contextMenuWorkspaceIds: [UUID] + let allContextMenuWorkspacesHideTerminalScrollBar: Bool let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool @@ -13170,19 +13177,11 @@ private struct TabItemView: View, Equatable { } } - private func allTargetWorkspacesHideTerminalScrollBar(_ targetIds: [UUID]) -> Bool { - guard !targetIds.isEmpty else { return false } - return targetIds.allSatisfy { targetId in - tabManager.tabs.first(where: { $0.id == targetId })?.terminalScrollBarHidden == true - } - } - @ViewBuilder private var workspaceContextMenu: some View { let targetIds = contextMenuWorkspaceIds let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() - let hidesTerminalScrollBarForAllTargets = allTargetWorkspacesHideTerminalScrollBar(targetIds) let shouldPin = !tab.isPinned let reconnectLabel = contextMenuLabel( multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), @@ -13290,7 +13289,7 @@ private struct TabItemView: View, Equatable { Label { Text(String(localized: "contextMenu.workspaceSettings.hideTerminalScrollBar", defaultValue: "Hide Terminal Scroll Bar")) } icon: { - if hidesTerminalScrollBarForAllTargets { + if allContextMenuWorkspacesHideTerminalScrollBar { Image(systemName: "checkmark") } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index bc9609918cf6..4624e412f0f4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -11349,7 +11349,9 @@ final class GhosttySurfaceScrollView: NSView { } private func updateWorkspaceTerminalScrollBarObserver(_ workspace: Workspace?) { - guard observedWorkspaceTerminalScrollBar !== workspace || workspaceTerminalScrollBarObserver == nil else { + if let observedWorkspaceTerminalScrollBar, + observedWorkspaceTerminalScrollBar === workspace, + workspaceTerminalScrollBarObserver != nil { return } From b6b6a345870856e8f18458525102211b9a4ac2d3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 9 Apr 2026 17:38:59 -0700 Subject: [PATCH 07/15] Fix sidebar scrollbar context menu compile error --- Sources/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c65dc9735e8c..07898a89f560 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9937,6 +9937,7 @@ struct VerticalTabsSidebar: View { let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut let tabItemSettings = tabItemSettingsStore.snapshot + let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) From 99c161235b56b5e1f7866908c09096fe1c92f995 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 02:52:34 -0700 Subject: [PATCH 08/15] Fix scrollbar geometry regressions --- Sources/GhosttyTerminalView.swift | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4624e412f0f4..7db4a3721509 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9179,10 +9179,7 @@ final class GhosttySurfaceScrollView: NSView { let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) _ = setFrameIfNeeded(scrollView, to: bounds) - let targetSize = CGSize( - width: max(0, scrollView.bounds.width - terminalScrollBarReservedWidth()), - height: scrollView.bounds.height - ) + let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) #endif @@ -9403,8 +9400,9 @@ final class GhosttySurfaceScrollView: NSView { let workspace = terminalSurface.owningWorkspace() cachedOwningWorkspace = workspace updateWorkspaceTerminalScrollBarObserver(workspace) - // Preserve the bootstrap 800x600 surface until the host has real bounds. - guard bounds.width > 0, bounds.height > 0 else { return } + // Preserve the bootstrap 800x600 surface until portal reattach churn + // has produced a real host size instead of a transient 1x1 placeholder. + guard bounds.width > 1, bounds.height > 1 else { return } _ = synchronizeGeometryAndContent() } @@ -11417,12 +11415,6 @@ final class GhosttySurfaceScrollView: NSView { terminalScrollBarAllowedBySettings() && surfaceHasScrollback() } - private func terminalScrollBarReservedWidth() -> CGFloat { - // Keep the PTY width stable while scrollback appears/disappears so - // entering alternate-screen TUIs does not reflow the surface. - guard terminalScrollBarAllowedBySettings() else { return 0 } - return ceil(NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay)) - } } // MARK: - NSTextInputClient From 50a5205ea7b1a55578895f43e969dcd9713f2cf2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 03:09:15 -0700 Subject: [PATCH 09/15] Fix stale scrollbar context menu state --- Sources/ContentView.swift | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 07898a89f560..f08fd6c43eb8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9937,7 +9937,6 @@ struct VerticalTabsSidebar: View { let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut let tabItemSettings = tabItemSettingsStore.snapshot - let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) @@ -9967,10 +9966,6 @@ struct VerticalTabsSidebar: View { let contextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedContextTargetIds : [tab.id] - let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && - contextMenuWorkspaceIds.allSatisfy { workspaceId in - tabsById[workspaceId]?.terminalScrollBarHidden == true - } let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedRemoteContextMenuWorkspaceIds : (tab.isRemoteWorkspace ? [tab.id] : []) @@ -10024,7 +10019,6 @@ struct VerticalTabsSidebar: View { draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, contextMenuWorkspaceIds: contextMenuWorkspaceIds, - allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, @@ -12433,7 +12427,6 @@ private struct TabItemView: View, Equatable { lhs.rowSpacing == rhs.rowSpacing && lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.allContextMenuWorkspacesHideTerminalScrollBar == rhs.allContextMenuWorkspacesHideTerminalScrollBar && lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && @@ -12464,7 +12457,6 @@ private struct TabItemView: View, Equatable { @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? let contextMenuWorkspaceIds: [UUID] - let allContextMenuWorkspacesHideTerminalScrollBar: Bool let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool @@ -12699,6 +12691,10 @@ private struct TabItemView: View, Equatable { settings.visibleAuxiliaryDetails } + private var allContextMenuWorkspacesHideTerminalScrollBar: Bool { + allWorkspacesHideTerminalScrollBar(targetIds: contextMenuWorkspaceIds) + } + var body: some View { let _ = workspaceObservationGeneration let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") @@ -13178,6 +13174,14 @@ private struct TabItemView: View, Equatable { } } + private func allWorkspacesHideTerminalScrollBar(targetIds: [UUID]) -> Bool { + guard !targetIds.isEmpty else { return false } + let workspacesById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) + return targetIds.allSatisfy { targetId in + workspacesById[targetId]?.terminalScrollBarHidden == true + } + } + @ViewBuilder private var workspaceContextMenu: some View { let targetIds = contextMenuWorkspaceIds @@ -14001,10 +14005,7 @@ private struct TabItemView: View, Equatable { } private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { - let workspacesById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) - let currentlyHidden = !targetIds.isEmpty && targetIds.allSatisfy { targetId in - workspacesById[targetId]?.terminalScrollBarHidden == true - } + let currentlyHidden = allWorkspacesHideTerminalScrollBar(targetIds: targetIds) let hideScrollBar = !currentlyHidden for targetId in targetIds { tabManager.setWorkspaceTerminalScrollBarHidden(tabId: targetId, hidden: hideScrollBar) From 1ff9532565d9acdb224386a62bd1a4474751b30c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 03:24:09 -0700 Subject: [PATCH 10/15] Stabilize zsh prompt TERM handoff test --- cmuxTests/GhosttyConfigTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 718a47b0d23e..1ac6f75ed182 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2825,8 +2825,10 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { fi cmux_test_ready() { + [[ -e "$CMUX_TEST_READY" ]] && return 0 print -r -- "PRE=$CMUX_STARTUP_THEME_TERM|$CMUX_STARTUP_THEME_BRANCH|$TERM|${CMUX_ZSH_RESTORE_TERM-unset}" > "$CMUX_TEST_OUTPUT" : > "$CMUX_TEST_READY" + precmd_functions=(${precmd_functions:#cmux_test_ready}) } precmd_functions+=(cmux_test_ready) """ From da2b21b4c5c0576a2372542ef941e140e806b369 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 03:46:03 -0700 Subject: [PATCH 11/15] Keep scrollbar visible until scrollback is known --- Sources/GhosttyTerminalView.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7db4a3721509..b2996258f8b9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -11402,8 +11402,8 @@ final class GhosttySurfaceScrollView: NSView { return true } - private func surfaceHasScrollback() -> Bool { - guard let scrollbar = surfaceView.scrollbar else { return false } + private func surfaceHasScrollback() -> Bool? { + guard let scrollbar = surfaceView.scrollbar else { return nil } // Embedded Ghostty exposes alternate-screen TUIs to the wrapper as a // viewport with no additional scrollback (`total <= len`). Treat that // as the signal to suppress the overlay scrollbar so full-screen apps @@ -11412,7 +11412,14 @@ final class GhosttySurfaceScrollView: NSView { } private func shouldShowTerminalScrollBar() -> Bool { - terminalScrollBarAllowedBySettings() && surfaceHasScrollback() + 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 } } From 2dbb59af55a1aa4f1725da074d60a047860201b6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 04:03:21 -0700 Subject: [PATCH 12/15] Precompute sidebar scrollbar menu state --- Sources/ContentView.swift | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f08fd6c43eb8..292a394db35f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9944,6 +9944,9 @@ struct VerticalTabsSidebar: View { let selectedContextTargetIds = orderedSelectedTabs.map(\.id) let selectedRemoteContextMenuTargets = orderedSelectedTabs.filter { $0.isRemoteWorkspace } let selectedRemoteContextMenuWorkspaceIds = selectedRemoteContextMenuTargets.map(\.id) + let workspaceTerminalScrollBarHiddenById = Dictionary( + uniqueKeysWithValues: tabs.map { ($0.id, $0.terminalScrollBarHidden) } + ) let allSelectedRemoteContextMenuTargetsConnecting = !selectedRemoteContextMenuTargets.isEmpty && selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting } let allSelectedRemoteContextMenuTargetsDisconnected = !selectedRemoteContextMenuTargets.isEmpty && @@ -9975,6 +9978,10 @@ struct VerticalTabsSidebar: View { let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets ? allSelectedRemoteContextMenuTargetsDisconnected : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) + let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && + contextMenuWorkspaceIds.allSatisfy { workspaceId in + workspaceTerminalScrollBarHiddenById[workspaceId] == true + } let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) let liveLatestNotificationText: String? = { guard showsSidebarNotificationMessage, @@ -10022,6 +10029,7 @@ struct VerticalTabsSidebar: View { remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, + allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, settings: tabItemSettings, livePresentation: livePresentation, frozenPresentation: $frozenTabItemPresentation @@ -12430,6 +12438,7 @@ private struct TabItemView: View, Equatable { lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && + lhs.allContextMenuWorkspacesHideTerminalScrollBar == rhs.allContextMenuWorkspacesHideTerminalScrollBar && lhs.settings == rhs.settings } @@ -12460,6 +12469,7 @@ private struct TabItemView: View, Equatable { let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool + let allContextMenuWorkspacesHideTerminalScrollBar: Bool let settings: SidebarTabItemSettingsSnapshot let livePresentation: SidebarTabItemPresentationSnapshot @Binding var frozenPresentation: SidebarTabItemPresentationSnapshot? @@ -12691,10 +12701,6 @@ private struct TabItemView: View, Equatable { settings.visibleAuxiliaryDetails } - private var allContextMenuWorkspacesHideTerminalScrollBar: Bool { - allWorkspacesHideTerminalScrollBar(targetIds: contextMenuWorkspaceIds) - } - var body: some View { let _ = workspaceObservationGeneration let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") @@ -13174,14 +13180,6 @@ private struct TabItemView: View, Equatable { } } - private func allWorkspacesHideTerminalScrollBar(targetIds: [UUID]) -> Bool { - guard !targetIds.isEmpty else { return false } - let workspacesById = Dictionary(uniqueKeysWithValues: tabManager.tabs.map { ($0.id, $0) }) - return targetIds.allSatisfy { targetId in - workspacesById[targetId]?.terminalScrollBarHidden == true - } - } - @ViewBuilder private var workspaceContextMenu: some View { let targetIds = contextMenuWorkspaceIds @@ -14005,7 +14003,9 @@ private struct TabItemView: View, Equatable { } private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { - let currentlyHidden = allWorkspacesHideTerminalScrollBar(targetIds: targetIds) + let currentlyHidden = !targetIds.isEmpty && targetIds.allSatisfy { targetId in + tabManager.tabs.first(where: { $0.id == targetId })?.terminalScrollBarHidden == true + } let hideScrollBar = !currentlyHidden for targetId in targetIds { tabManager.setWorkspaceTerminalScrollBarHidden(tabId: targetId, hidden: hideScrollBar) From 1317682a77a2c0ab1580ecdef1a8ce174f08e2ca Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 10 Apr 2026 04:20:00 -0700 Subject: [PATCH 13/15] Refresh sidebar scrollbar menu state --- Sources/ContentView.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 292a394db35f..99d7eebf89b6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9910,6 +9910,7 @@ struct VerticalTabsSidebar: View { @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? @State private var frozenTabItemPresentation: SidebarTabItemPresentationSnapshot? + @State private var terminalScrollBarVisibilityGeneration: UInt64 = 0 @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @@ -9932,6 +9933,7 @@ struct VerticalTabsSidebar: View { } var body: some View { + let _ = terminalScrollBarVisibilityGeneration let tabs = tabManager.tabs let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 @@ -10146,6 +10148,19 @@ struct VerticalTabsSidebar: View { #endif draggedTabId = nil } + .onReceive( + NotificationCenter.default.publisher(for: Workspace.terminalScrollBarHiddenDidChangeNotification) + .receive(on: RunLoop.main) + ) { notification in + guard let workspace = notification.object as? Workspace, + tabManager.tabs.contains(where: { $0 === workspace }) else { + return + } + + // Workspace scrollbar visibility changes do not publish on TabManager.tabs, + // so bump a local generation to refresh the precomputed context-menu state. + terminalScrollBarVisibilityGeneration &+= 1 + } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } From 09b4bf003a806578b35f8696880a21b7c9a05e00 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 12 Apr 2026 19:02:04 -0700 Subject: [PATCH 14/15] Avoid duplicate scrollbar geometry reconcile --- Sources/Workspace.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a91f031f3420..91ea1f269fcf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7537,7 +7537,6 @@ final class Workspace: Identifiable, ObservableObject { name: Self.terminalScrollBarHiddenDidChangeNotification, object: self ) - scheduleTerminalGeometryReconcile() } private static func normalizedCustomDescription(_ description: String?) -> String? { From da1b45d57503cdf2b6ffc3e20fc9943b0b586aa5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Apr 2026 16:17:47 -0700 Subject: [PATCH 15/15] Use canonical terminal scrollbar visibility check --- Sources/GhosttyTerminalView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b2996258f8b9..1dc14ffd1d57 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -11294,7 +11294,7 @@ final class GhosttySurfaceScrollView: NSView { pendingExplicitWheelScroll = false } surfaceView.scrollbar = scrollbar - let isVisible = terminalScrollBarAllowedBySettings() && scrollbar.total > scrollbar.len + let isVisible = shouldShowTerminalScrollBar() if wasVisible != isVisible { _ = synchronizeGeometryAndContent() return