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/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/ContentView.swift b/Sources/ContentView.swift index da5491e65839..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 @@ -9944,6 +9946,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 +9980,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 +10031,7 @@ struct VerticalTabsSidebar: View { remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, + allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, settings: tabItemSettings, livePresentation: livePresentation, frozenPresentation: $frozenTabItemPresentation @@ -10138,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) } @@ -12430,6 +12453,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 +12484,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? @@ -13275,6 +13300,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 allContextMenuWorkspacesHideTerminalScrollBar { + Image(systemName: "checkmark") + } + } + } + } + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { if tab.customColor != nil { Button { @@ -13978,6 +14017,16 @@ private struct TabItemView: View, Equatable { } } + private func toggleWorkspaceTerminalScrollBarHidden(targetIds: [UUID]) { + 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) + } + } + 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..1dc14ffd1d57 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8544,8 +8544,11 @@ final class GhosttySurfaceScrollView: NSView { private var activeImageTransferOperation: TerminalImageTransferOperation? 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 @@ -9062,6 +9065,14 @@ final class GhosttySurfaceScrollView: NSView { self?.handlePreferredScrollerStyleChange() }) + observers.append(NotificationCenter.default.addObserver( + forName: TerminalScrollBarSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleTerminalScrollBarPreferenceChange() + }) + } required init?(coder: NSCoder) { @@ -9077,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() @@ -9161,6 +9175,7 @@ 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) @@ -9202,6 +9217,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) @@ -9379,6 +9397,13 @@ final class GhosttySurfaceScrollView: NSView { func attachSurface(_ terminalSurface: TerminalSurface) { surfaceView.attachSurface(terminalSurface) + let workspace = terminalSurface.owningWorkspace() + cachedOwningWorkspace = workspace + updateWorkspaceTerminalScrollBarObserver(workspace) + // 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() } func setFocusHandler(_ handler: (() -> Void)?) { @@ -9817,7 +9842,7 @@ final class GhosttySurfaceScrollView: NSView { } func refreshHostBackgroundAfterGhosttyConfigReload() { - synchronizeScrollbarAppearance() + _ = synchronizeGeometryAndContent() surfaceView.applySurfaceBackground() surfaceView.applyWindowBackgroundIfActive() } @@ -11130,10 +11155,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 +11287,36 @@ final class GhosttySurfaceScrollView: NSView { guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { return } + let wasVisible = scrollView.hasVerticalScroller 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() { @@ -11295,12 +11330,47 @@ final class GhosttySurfaceScrollView: NSView { synchronizeScrollbarAppearance() // 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. + // scroller preference without perturbing hosted terminal geometry. scrollView.tile() _ = synchronizeCoreSurface() } + private func handleTerminalScrollBarPreferenceChange() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.handleTerminalScrollBarPreferenceChange() + } + return + } + + _ = synchronizeGeometryAndContent() + } + + private func updateWorkspaceTerminalScrollBarObserver(_ workspace: Workspace?) { + if let observedWorkspaceTerminalScrollBar, + observedWorkspaceTerminalScrollBar === workspace, + workspaceTerminalScrollBarObserver != nil { + 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 @@ -11311,6 +11381,47 @@ final class GhosttySurfaceScrollView: NSView { } return contentHeight } + + private func owningWorkspace() -> Workspace? { + 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 + } + + 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 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 + // like nvim/htop do not pin it on top of the rightmost cell column. + return scrollbar.total > scrollbar.len + } + + 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 + } + } // MARK: - NSTextInputClient diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 276754dbd1b0..97c0499b6731 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, @@ -1101,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) @@ -1130,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 @@ -1185,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 @@ -1324,6 +1356,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..91ea1f269fcf 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 @@ -6480,12 +6482,17 @@ 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? @Published var customDescription: String? @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" + @Published private(set) var terminalScrollBarHidden: Bool = false @Published var currentDirectory: String private(set) var preferredBrowserProfileID: UUID? @@ -6630,6 +6637,7 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($customDescription), sidebarObservationSignal($isPinned), sidebarObservationSignal($customColor), + sidebarObservationSignal($terminalScrollBarHidden), ] return Publishers.MergeMany(publishers).eraseToAnyPublisher() @@ -7522,6 +7530,15 @@ final class Workspace: Identifiable, ObservableObject { } } + func setTerminalScrollBarHidden(_ hidden: Bool) { + guard terminalScrollBarHidden != hidden else { return } + terminalScrollBarHidden = hidden + NotificationCenter.default.post( + name: Self.terminalScrollBarHiddenDidChangeNotification, + object: self + ) + } + 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..8cb72aed4fe2 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -96,6 +96,23 @@ 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 { + return defaultShowScrollBar + } + return defaults.bool(forKey: showScrollBarKey) + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" @@ -4102,6 +4119,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 @@ -4214,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) } @@ -5156,6 +5186,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: showTerminalScrollBarBinding) + .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 +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 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) """ 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.",