From 927b0eb2d1aa4bee8264f75daa05b45135e99112 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:39:59 -0800 Subject: [PATCH 001/136] Implement session persistence pass 1 with multi-window restore --- GhosttyTabs.xcodeproj/project.pbxproj | 12 + .../cmux-bash-integration.bash | 12 + .../cmux-zsh-integration.zsh | 12 + Sources/AppDelegate.swift | 901 +++++++++++++++++- Sources/ContentView.swift | 45 +- Sources/GhosttyTerminalView.swift | 11 +- Sources/Panels/TerminalPanel.swift | 4 +- Sources/SessionPersistence.swift | 471 +++++++++ Sources/SidebarSelectionState.swift | 7 +- Sources/TabManager.swift | 69 ++ Sources/TerminalController.swift | 137 +++ Sources/Update/UpdateDelegate.swift | 2 + Sources/Workspace.swift | 493 +++++++++- Sources/cmuxApp.swift | 13 +- .../AppDelegateShortcutRoutingTests.swift | 214 +++++ cmuxTests/SessionPersistenceTests.swift | 382 ++++++++ ..._unfocused_workspace_multi_window_cycle.py | 324 +++++++ ...tore_unfocused_workspace_relaunch_cycle.py | 229 +++++ ...t_shell_scrollback_restore_color_replay.py | 62 ++ ...rollback_restore_replay_path_regression.py | 67 ++ 20 files changed, 3434 insertions(+), 33 deletions(-) create mode 100644 Sources/SessionPersistence.swift create mode 100644 cmuxTests/AppDelegateShortcutRoutingTests.swift create mode 100644 cmuxTests/SessionPersistenceTests.swift create mode 100644 tests/test_session_restore_unfocused_workspace_multi_window_cycle.py create mode 100644 tests/test_session_restore_unfocused_workspace_relaunch_cycle.py create mode 100644 tests/test_shell_scrollback_restore_color_replay.py create mode 100644 tests/test_shell_scrollback_restore_replay_path_regression.py diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 65cc12e6..57d28a2b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; + A5001600 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001601 /* SessionPersistence.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; @@ -75,6 +76,8 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -177,6 +180,7 @@ A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; + A5001601 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -197,6 +201,8 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -345,6 +351,7 @@ A5001219 /* WindowToolbarController.swift */, A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, + A5001601 /* SessionPersistence.swift */, ); path = Sources; sourceTree = ""; @@ -402,6 +409,8 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, + F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -574,6 +583,7 @@ A5001209 /* WindowToolbarController.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, + A5001600 /* SessionPersistence.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -602,6 +612,8 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, + F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 4f8c832f..070b33e9 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -23,6 +23,18 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 6c9575f0..3121788f 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -24,6 +24,18 @@ _cmux_send() { fi } +_cmux_restore_scrollback_once() { + local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}" + [[ -n "$path" ]] || return 0 + unset CMUX_RESTORE_SCROLLBACK_FILE + + if [[ -r "$path" ]]; then + /bin/cat -- "$path" 2>/dev/null || true + /bin/rm -f -- "$path" >/dev/null 2>&1 || true + fi +} +_cmux_restore_scrollback_once + # Throttle heavy work to avoid prompt latency. typeset -g _CMUX_PWD_LAST_PWD="" typeset -g _CMUX_GIT_LAST_PWD="" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 23197bba..0f149ffa 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -64,6 +64,14 @@ enum WorkspaceShortcutMapper { } } +private extension NSScreen { + var cmuxDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value + } +} + func browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, @@ -186,12 +194,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect + } + weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? weak var fullscreenControlsViewModel: TitlebarControlsViewModel? weak var sidebarSelectionState: SidebarSelectionState? private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? @@ -231,6 +246,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupGotoSplitUITest = false private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] private func childExitKeyboardProbePath() -> String? { let env = ProcessInfo.processInfo.environment @@ -272,11 +289,66 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] private var mainWindowControllers: [MainWindowController] = [] + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var didHandleExplicitOpenIntentAtStartup = false + private var isTerminatingApp = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false var updateViewModel: UpdateViewModel { updateController.viewModel } +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" + } + + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") + } + + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif + override init() { super.init() Self.shared = self @@ -433,17 +505,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + return .terminateNow + } + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() TerminalController.shared.stop() BrowserHistoryStore.shared.flushPendingSaves() PostHogAnalytics.shared.flush() notificationStore?.clearAll() + enableSuddenTerminationIfNeeded() + } + + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + _ = saveSessionSnapshot(includeScrollback: false) + } + + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { self.tabManager = tabManager self.notificationStore = notificationStore self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -469,6 +565,431 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + startupSessionSnapshot = SessionPersistenceStore.load() + } + + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let startupSessionSnapshot else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + guard let primaryWindowSnapshot = startupSessionSnapshot.windows.first else { return } + + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + + let additionalWindows = startupSessionSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + } + } + + self.startupSessionSnapshot = nil + } + + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? + ) { + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection + + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) + } + } + + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + let fallbackDisplay = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays, + fallbackDisplay: fallbackDisplay + ) + } + + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil + } + + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } + + guard !availableDisplays.isEmpty else { return frame } + + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact + } + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { + return nil + } + + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) + } + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display + } + + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } + } + + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) + } + + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height + + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) + } + + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) + + return CGRect(x: x, y: y, width: width, height: height) + } + + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) + } + + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) + } + + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } + + return SessionDisplaySnapshot( + displayID: screen.cmuxDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) + ) + } + + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + guard !isRunningUnderXCTest(env) else { return } + + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + _ = self?.saveSessionSnapshot(includeScrollback: false) + } + sessionAutosaveTimer = timer + timer.resume() + } + + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + } + + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } + } + lifecycleSnapshotObservers.append(powerOffObserver) + + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) + } + } + } + lifecycleSnapshotObservers.append(sessionResignObserver) + } + + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true + } + + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false + } + + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } + return false + } + return SessionPersistenceStore.save(snapshot) + } + + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } + + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey + } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) + ) + } + + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -481,6 +1002,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let key = ObjectIdentifier(window) if let existing = mainWindowContexts[key] { existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) } else { mainWindowContexts[key] = MainWindowContext( windowId: windowId, @@ -502,6 +1026,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if window.isKeyWindow { setActiveMainWindow(window) } + + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) + } } struct MainWindowSummary { @@ -591,6 +1120,83 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) + } + + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window + return + } + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window + return + } + + mainWindowContexts[desiredKey] = context + context.window = window + } + + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } + + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context + } + + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context + } + + return nil + } + + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil + } + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) + } + return removed + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -621,6 +1227,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent target: ServiceOpenTarget, error: AutoreleasingUnsafeMutablePointer ) { + didHandleExplicitOpenIntentAtStartup = true + if !didAttemptStartupSessionRestore { + startupSessionSnapshot = nil + didAttemptStartupSessionRestore = true + } + let pathURLs = servicePathURLs(from: pasteboard) guard !pathURLs.isEmpty else { error.pointee = Self.serviceErrorNoPath @@ -672,38 +1284,222 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func openWorkspaceFromService(workingDirectory: String) { - if let context = preferredMainWindowContextForServiceWorkspace(), - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - _ = context.tabManager.addWorkspace(workingDirectory: workingDirectory) + if addWorkspaceInPreferredMainWindow( + workingDirectory: workingDirectory, + shouldBringToFront: true, + debugSource: "service.openTab" + ) != nil { return } _ = createMainWindow(initialWorkingDirectory: workingDirectory) } - private func preferredMainWindowContextForServiceWorkspace() -> MainWindowContext? { + @discardableResult + func addWorkspaceInPreferredMainWindow( + workingDirectory: String? = nil, + shouldBringToFront: Bool = false, + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> UUID? { + #if DEBUG + logWorkspaceCreationRouting( + phase: "request", + source: debugSource, + reason: "add_workspace", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + #if DEBUG + logWorkspaceCreationRouting( + phase: "no_context", + source: debugSource, + reason: "context_selection_failed", + event: event, + chosenContext: nil, + workingDirectory: workingDirectory + ) + #endif + return nil + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + } + + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id + } + + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context + } + if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let context = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let context = contextForMainTerminalWindow(keyWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context + ) + #endif return context } if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - let context = mainWindowContexts[ObjectIdentifier(mainWindow)] { + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif return context } - return mainWindowContexts.values.first + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } + } + + let fallback = mainWindowContexts.values.first + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback + ) + #endif + return fallback + } + + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber >= 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context + ) + #endif + return context + } + + if event.windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", + event: event, + chosenContext: context + ) + #endif + return context + } + + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil } @discardableResult - func createMainWindow(initialWorkingDirectory: String? = nil) -> UUID { + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { let windowId = UUID() let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - let sidebarState = SidebarState() - let sidebarSelectionState = SidebarSelectionState() + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) + } + + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) let notificationStore = TerminalNotificationStore.shared let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) @@ -722,7 +1518,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false - window.center() + if let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + } window.contentView = NSHostingView(rootView: root) // Apply shared window styling. @@ -805,8 +1605,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func showNotificationsPopoverFromMenuBar() { let context: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let keyContext = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let keyContext = contextForMainTerminalWindow(keyWindow) { return keyContext } if let first = mainWindowContexts.values.first { @@ -1804,10 +2603,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+N semantics: // - If there are no main windows, create a new window. // - Otherwise, create a new workspace in the active window. - if tabManager == nil || mainWindowContexts.isEmpty { + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif openNewMainWindow(nil) - } else { - tabManager?.addTab() } return true } @@ -2355,6 +3172,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func debugHandleCustomShortcut(event: NSEvent) -> Bool { handleCustomShortcut(event: event) } + + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { + return false + } + + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + debugDetachedContextWindows.append(detachedWindow) + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil + } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window + return true + } #endif private func findButton(in view: NSView, titled title: String) -> NSButton? { @@ -2697,8 +3542,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func setActiveMainWindow(_ window: NSWindow) { - guard isMainTerminalWindow(window) else { return } - guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return } + guard let context = contextForMainTerminalWindow(window) else { return } tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState @@ -2706,8 +3550,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func unregisterMainWindow(_ window: NSWindow) { - let key = ObjectIdentifier(window) - guard let removed = mainWindowContexts.removeValue(forKey: key) else { return } + guard let removed = unregisterMainWindowContext(for: window) else { return } // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { @@ -2720,8 +3563,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Repoint "active" pointers to any remaining main terminal window. let nextContext: MainWindowContext? = { if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - let ctx = mainWindowContexts[ObjectIdentifier(keyWindow)] { + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { return ctx } return mainWindowContexts.values.first @@ -2739,6 +3581,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.setActiveTabManager(nil) } } + + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot(includeScrollback: false) + } } private func isMainTerminalWindow(_ window: NSWindow) -> Bool { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4ad00b12..53f9da9a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -152,7 +152,14 @@ enum WindowGlassEffect { } final class SidebarState: ObservableObject { - @Published var isVisible: Bool = true + @Published var isVisible: Bool + @Published var persistedWidth: CGFloat + + init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { + self.isVisible = isVisible + let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) + self.persistedWidth = CGFloat(sanitized) + } func toggle() { isVisible.toggle() @@ -844,6 +851,15 @@ struct ContentView: View { (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 } + private func normalizedSidebarWidth(_ candidate: CGFloat) -> CGFloat { + let minWidth = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) + let maxWidth = max(minWidth, maxSidebarWidth) + if !candidate.isFinite { + return CGFloat(SessionPersistencePolicy.defaultSidebarWidth) + } + return max(minWidth, min(maxWidth, candidate)) + } + private func activateSidebarResizerCursor() { sidebarResizerCursorReleaseWorkItem?.cancel() sidebarResizerCursorReleaseWorkItem = nil @@ -1317,6 +1333,13 @@ struct ContentView: View { reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() + let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) + if abs(sidebarWidth - restoredWidth) > 0.5 { + sidebarWidth = restoredWidth + } + if abs(sidebarState.persistedWidth - restoredWidth) > 0.5 { + sidebarState.persistedWidth = restoredWidth + } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -1456,6 +1479,14 @@ struct ContentView: View { }) view = AnyView(view.onChange(of: sidebarWidth) { _ in + let sanitized = normalizedSidebarWidth(sidebarWidth) + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + return + } + if abs(sidebarState.persistedWidth - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + } updateSidebarResizerBandState() }) @@ -1463,6 +1494,18 @@ struct ContentView: View { updateSidebarResizerBandState() }) + view = AnyView(view.onChange(of: sidebarState.persistedWidth) { newValue in + let sanitized = normalizedSidebarWidth(newValue) + if abs(newValue - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + return + } + guard !isResizerDragging else { return } + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + } + }) + view = AnyView(view.ignoresSafeArea()) view = AnyView(view.onDisappear { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 78121fa0..094518d5 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1126,6 +1126,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? + private let additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 @@ -1170,13 +1171,15 @@ final class TerminalSurface: Identifiable, ObservableObject { tabId: UUID, context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, - workingDirectory: String? = nil + workingDirectory: String? = nil, + additionalEnvironment: [String: String] = [:] ) { self.id = UUID() self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) + self.additionalEnvironment = additionalEnvironment // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. @@ -1426,6 +1429,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + if !additionalEnvironment.isEmpty { + for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty { + env[key] = value + } + } + if !env.isEmpty { envVars.reserveCapacity(env.count) envStorage.reserveCapacity(env.count) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index b9a9d767..ede60c40 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -83,13 +83,15 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, + additionalEnvironment: [String: String] = [:], portOrdinal: Int = 0 ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, - workingDirectory: workingDirectory + workingDirectory: workingDirectory, + additionalEnvironment: additionalEnvironment ) surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift new file mode 100644 index 00000000..f4459a68 --- /dev/null +++ b/Sources/SessionPersistence.swift @@ -0,0 +1,471 @@ +import CoreGraphics +import Foundation +import Bonsplit + +enum SessionSnapshotSchema { + static let currentVersion = 1 +} + +enum SessionPersistencePolicy { + static let defaultSidebarWidth: Double = 200 + static let minimumSidebarWidth: Double = 186 + static let maximumSidebarWidth: Double = 600 + static let minimumWindowWidth: Double = 300 + static let minimumWindowHeight: Double = 200 + static let autosaveInterval: TimeInterval = 8.0 + static let maxWindowsPerSnapshot: Int = 12 + static let maxWorkspacesPerWindow: Int = 128 + static let maxPanelsPerWorkspace: Int = 512 + static let maxScrollbackLinesPerTerminal: Int = 4000 + static let maxScrollbackCharactersPerTerminal: Int = 400_000 + + static func sanitizedSidebarWidth(_ candidate: Double?) -> Double { + let fallback = defaultSidebarWidth + guard let candidate, candidate.isFinite else { return fallback } + return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth) + } + + static func truncatedScrollback(_ text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + if text.count <= maxScrollbackCharactersPerTerminal { + return text + } + let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal) + let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart) + return String(text[safeStart...]) + } + + /// If truncation starts in the middle of an ANSI CSI escape sequence, advance + /// to the first printable character after that sequence to avoid replaying + /// malformed control bytes. + private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index { + guard initialStart > text.startIndex else { return initialStart } + let escape = "\u{001B}" + + guard let lastEscape = text[.. String.Index? { + var index = text.index(after: csiMarker) + while index < upperBound { + guard let scalar = text[index].unicodeScalars.first?.value else { + index = text.index(after: index) + continue + } + if scalar >= 0x40, scalar <= 0x7E { + return index + } + index = text.index(after: index) + } + return nil + } +} + +enum SessionRestorePolicy { + static func isRunningUnderAutomatedTests( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if environment["CMUX_UI_TEST_MODE"] == "1" { + return true + } + if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { + return true + } + if environment["XCTestConfigurationFilePath"] != nil { + return true + } + if environment["XCTestBundlePath"] != nil { + return true + } + if environment["XCTestSessionIdentifier"] != nil { + return true + } + if environment["XCInjectBundle"] != nil { + return true + } + if environment["XCInjectBundleInto"] != nil { + return true + } + if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { + return true + } + return false + } + + static func shouldAttemptRestore( + arguments: [String] = CommandLine.arguments, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" { + return false + } + if isRunningUnderAutomatedTests(environment: environment) { + return false + } + + let extraArgs = arguments + .dropFirst() + .filter { !$0.hasPrefix("-psn_") } + + // Any explicit launch argument is treated as an explicit open intent. + return extraArgs.isEmpty + } +} + +struct SessionRectSnapshot: Codable, Equatable, Sendable { + let x: Double + let y: Double + let width: Double + let height: Double + + init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(_ rect: CGRect) { + self.x = Double(rect.origin.x) + self.y = Double(rect.origin.y) + self.width = Double(rect.size.width) + self.height = Double(rect.size.height) + } + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +struct SessionDisplaySnapshot: Codable, Sendable { + var displayID: UInt32? + var frame: SessionRectSnapshot? + var visibleFrame: SessionRectSnapshot? +} + +enum SessionSidebarSelection: String, Codable, Sendable, Equatable { + case tabs + case notifications + + init(selection: SidebarSelection) { + switch selection { + case .tabs: + self = .tabs + case .notifications: + self = .notifications + } + } + + var sidebarSelection: SidebarSelection { + switch self { + case .tabs: + return .tabs + case .notifications: + return .notifications + } + } +} + +struct SessionSidebarSnapshot: Codable, Sendable { + var isVisible: Bool + var selection: SessionSidebarSelection + var width: Double? +} + +struct SessionStatusEntrySnapshot: Codable, Sendable { + var key: String + var value: String + var icon: String? + var color: String? + var timestamp: TimeInterval +} + +struct SessionLogEntrySnapshot: Codable, Sendable { + var message: String + var level: String + var source: String? + var timestamp: TimeInterval +} + +struct SessionProgressSnapshot: Codable, Sendable { + var value: Double + var label: String? +} + +struct SessionGitBranchSnapshot: Codable, Sendable { + var branch: String + var isDirty: Bool +} + +struct SessionTerminalPanelSnapshot: Codable, Sendable { + var workingDirectory: String? + var scrollback: String? +} + +struct SessionBrowserPanelSnapshot: Codable, Sendable { + var urlString: String? + var shouldRenderWebView: Bool + var pageZoom: Double + var developerToolsVisible: Bool +} + +struct SessionPanelSnapshot: Codable, Sendable { + var id: UUID + var type: PanelType + var title: String? + var customTitle: String? + var directory: String? + var isPinned: Bool + var isManuallyUnread: Bool + var gitBranch: SessionGitBranchSnapshot? + var listeningPorts: [Int] + var ttyName: String? + var terminal: SessionTerminalPanelSnapshot? + var browser: SessionBrowserPanelSnapshot? +} + +enum SessionSplitOrientation: String, Codable, Sendable { + case horizontal + case vertical + + init(_ orientation: SplitOrientation) { + switch orientation { + case .horizontal: + self = .horizontal + case .vertical: + self = .vertical + } + } + + var splitOrientation: SplitOrientation { + switch self { + case .horizontal: + return .horizontal + case .vertical: + return .vertical + } + } +} + +struct SessionPaneLayoutSnapshot: Codable, Sendable { + var panelIds: [UUID] + var selectedPanelId: UUID? +} + +struct SessionSplitLayoutSnapshot: Codable, Sendable { + var orientation: SessionSplitOrientation + var dividerPosition: Double + var first: SessionWorkspaceLayoutSnapshot + var second: SessionWorkspaceLayoutSnapshot +} + +indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { + case pane(SessionPaneLayoutSnapshot) + case split(SessionSplitLayoutSnapshot) + + private enum CodingKeys: String, CodingKey { + case type + case pane + case split + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "pane": + self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane)) + case "split": + self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split)) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .pane(let pane): + try container.encode("pane", forKey: .type) + try container.encode(pane, forKey: .pane) + case .split(let split): + try container.encode("split", forKey: .type) + try container.encode(split, forKey: .split) + } + } +} + +struct SessionWorkspaceSnapshot: Codable, Sendable { + var processTitle: String + var customTitle: String? + var isPinned: Bool + var currentDirectory: String + var focusedPanelId: UUID? + var layout: SessionWorkspaceLayoutSnapshot + var panels: [SessionPanelSnapshot] + var statusEntries: [SessionStatusEntrySnapshot] + var logEntries: [SessionLogEntrySnapshot] + var progress: SessionProgressSnapshot? + var gitBranch: SessionGitBranchSnapshot? +} + +struct SessionTabManagerSnapshot: Codable, Sendable { + var selectedWorkspaceIndex: Int? + var workspaces: [SessionWorkspaceSnapshot] +} + +struct SessionWindowSnapshot: Codable, Sendable { + var frame: SessionRectSnapshot? + var display: SessionDisplaySnapshot? + var tabManager: SessionTabManagerSnapshot + var sidebar: SessionSidebarSnapshot +} + +struct AppSessionSnapshot: Codable, Sendable { + var version: Int + var createdAt: TimeInterval + var windows: [SessionWindowSnapshot] +} + +enum SessionPersistenceStore { + static func load(fileURL: URL? = nil) -> AppSessionSnapshot? { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } + guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } + guard !snapshot.windows.isEmpty else { return nil } + return snapshot + } + + @discardableResult + static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false } + let directory = fileURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + + static func removeSnapshot(fileURL: URL? = nil) { + guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return } + try? FileManager.default.removeItem(at: fileURL) + } + + static func defaultSnapshotFileURL( + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + appSupportDirectory: URL? = nil + ) -> URL? { + let resolvedAppSupport: URL + if let appSupportDirectory { + resolvedAppSupport = appSupportDirectory + } else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + resolvedAppSupport = discovered + } else { + return nil + } + let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) + ? bundleIdentifier! + : "com.cmuxterm.app" + let safeBundleId = bundleId.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "_", + options: .regularExpression + ) + return resolvedAppSupport + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("session-\(safeBundleId).json", isDirectory: false) + } +} + +enum SessionScrollbackReplayStore { + static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE" + private static let directoryName = "cmux-session-scrollback" + private static let ansiEscape = "\u{001B}" + private static let ansiReset = "\u{001B}[0m" + + static func replayEnvironment( + for scrollback: String?, + tempDirectory: URL = FileManager.default.temporaryDirectory + ) -> [String: String] { + guard let replayText = normalizedScrollback(scrollback) else { return [:] } + guard let replayFileURL = writeReplayFile( + contents: replayText, + tempDirectory: tempDirectory + ) else { + return [:] + } + return [environmentKey: replayFileURL.path] + } + + private static func normalizedScrollback(_ scrollback: String?) -> String? { + guard let scrollback else { return nil } + guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil } + guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil } + return ansiSafeReplayText(truncated) + } + + /// Preserve ANSI color state safely across replay boundaries. + private static func ansiSafeReplayText(_ text: String) -> String { + guard text.contains(ansiEscape) else { return text } + var output = text + if !output.hasPrefix(ansiReset) { + output = ansiReset + output + } + if !output.hasSuffix(ansiReset) { + output += ansiReset + } + return output + } + + private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? { + guard let data = contents.data(using: .utf8) else { return nil } + let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true) + + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil + ) + let fileURL = directory + .appendingPathComponent(UUID().uuidString, isDirectory: false) + .appendingPathExtension("txt") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + return nil + } + } +} diff --git a/Sources/SidebarSelectionState.swift b/Sources/SidebarSelectionState.swift index 6fed3117..78ea1ab5 100644 --- a/Sources/SidebarSelectionState.swift +++ b/Sources/SidebarSelectionState.swift @@ -2,6 +2,9 @@ import SwiftUI @MainActor final class SidebarSelectionState: ObservableObject { - @Published var selection: SidebarSelection = .tabs -} + @Published var selection: SidebarSelection + init(selection: SidebarSelection = .tabs) { + self.selection = selection + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0bcd5ea6..cec81c03 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2774,6 +2774,75 @@ class TabManager: ObservableObject { #endif } +extension TabManager { + func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { + let workspaceSnapshots = tabs + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } + let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in + tabs.firstIndex(where: { $0.id == selectedTabId }) + } + return SessionTabManagerSnapshot( + selectedWorkspaceIndex: selectedWorkspaceIndex, + workspaces: workspaceSnapshots + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { + for tab in tabs { + unwireClosedBrowserTracking(for: tab) + } + + tabs.removeAll(keepingCapacity: false) + lastFocusedPanelByTab.removeAll() + pendingPanelTitleUpdates.removeAll() + tabHistory.removeAll() + historyIndex = -1 + isNavigatingHistory = false + pendingWorkspaceUnfocusTarget = nil + workspaceCycleCooldownTask?.cancel() + workspaceCycleCooldownTask = nil + isWorkspaceCycleHot = false + selectionSideEffectsGeneration &+= 1 + recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) + + let workspaceSnapshots = snapshot.workspaces + .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + for workspaceSnapshot in workspaceSnapshots { + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let workspace = Workspace( + title: workspaceSnapshot.processTitle, + workingDirectory: workspaceSnapshot.currentDirectory, + portOrdinal: ordinal + ) + workspace.restoreSessionSnapshot(workspaceSnapshot) + wireClosedBrowserTracking(for: workspace) + tabs.append(workspace) + } + + if tabs.isEmpty { + _ = addWorkspace(select: false) + } + + selectedTabId = nil + if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, + tabs.indices.contains(selectedWorkspaceIndex) { + selectedTabId = tabs[selectedWorkspaceIndex].id + } else { + selectedTabId = tabs.first?.id + } + + if let selectedTabId { + NotificationCenter.default.post( + name: .ghosttyDidFocusTab, + object: nil, + userInfo: [GhosttyNotificationKey.tabId: selectedTabId] + ) + } + } +} + // MARK: - Direction Types for Backwards Compatibility /// Split direction for backwards compatibility with old API diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3f61f26b..f276f2a6 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3486,6 +3486,143 @@ class TerminalController { return "OK \(base64)" } + private struct PasteboardItemSnapshot { + let representations: [(type: NSPasteboard.PasteboardType, data: Data)] + } + + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + + private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { + guard let items = pasteboard.pasteboardItems else { return [] } + return items.map { item in + let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in + guard let data = item.data(forType: type) else { return nil } + return (type: type, data: data) + } + return PasteboardItemSnapshot(representations: representations) + } + } + + private func restorePasteboardItems( + _ snapshots: [PasteboardItemSnapshot], + to pasteboard: NSPasteboard + ) { + _ = pasteboard.clearContents() + guard !snapshots.isEmpty else { return } + + let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in + guard !snapshot.representations.isEmpty else { return nil } + let item = NSPasteboardItem() + for representation in snapshot.representations { + item.setData(representation.data, forType: representation.type) + } + return item + } + guard !restoredItems.isEmpty else { return } + _ = pasteboard.writeObjects(restoredItems) + } + + private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? { + if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], + let firstURL = urls.first, + firstURL.isFileURL { + return firstURL.path + } + if let value = pasteboard.string(forType: .string) { + return value + } + return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text")) + } + + private func readTerminalTextFromVTExportForSnapshot( + terminalPanel: TerminalPanel, + lineLimit: Int? + ) -> String? { + // read_text strips style state; VT export keeps ANSI escape sequences. + let pasteboard = NSPasteboard.general + let snapshot = snapshotPasteboardItems(pasteboard) + defer { + restorePasteboardItems(snapshot, to: pasteboard) + } + + let initialChangeCount = pasteboard.changeCount + guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else { + return nil + } + guard pasteboard.changeCount != initialChangeCount else { + return nil + } + guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else { + return nil + } + + let fileURL = URL(fileURLWithPath: exportedPath) + defer { + try? FileManager.default.removeItem(at: fileURL) + if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) { + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + } + + guard let data = try? Data(contentsOf: fileURL), + var output = String(data: data, encoding: .utf8) else { + return nil + } + if let lineLimit { + output = tailTerminalLines(output, maxLines: lineLimit) + } + return output + } + + func readTerminalTextForSnapshot( + terminalPanel: TerminalPanel, + includeScrollback: Bool = false, + lineLimit: Int? = nil + ) -> String? { + if includeScrollback, + let vtOutput = readTerminalTextFromVTExportForSnapshot( + terminalPanel: terminalPanel, + lineLimit: lineLimit + ) { + return vtOutput + } + + let response = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { return nil } + let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + if base64.isEmpty { + return "" + } + guard let data = Data(base64Encoded: base64), + let decoded = String(data: data, encoding: .utf8) else { + return nil + } + return decoded + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 32e2304c..dfcd457c 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } } + @MainActor func updaterWillRelaunchApplication(_ updater: SPUUpdater) { + AppDelegate.shared?.persistSessionForUpdateRelaunch() TerminalController.shared.stop() NSApp.invalidateRestorableState() for window in NSApp.windows { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 547cd84b..e0180270 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -12,6 +12,487 @@ struct SidebarStatusEntry { let timestamp: Date } +private struct SessionPaneRestoreEntry { + let paneId: PaneID + let snapshot: SessionPaneLayoutSnapshot +} + +extension Workspace { + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { + let tree = bonsplitController.treeSnapshot() + let layout = sessionLayoutSnapshot(from: tree) + + let orderedPanelIds = sidebarOrderedPanelIds() + var seen: Set = [] + var allPanelIds: [UUID] = [] + for panelId in orderedPanelIds where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + allPanelIds.append(panelId) + } + + let panelSnapshots = allPanelIds + .prefix(SessionPersistencePolicy.maxPanelsPerWorkspace) + .compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) } + + let statusSnapshots = statusEntries.values + .sorted { lhs, rhs in lhs.key < rhs.key } + .map { entry in + SessionStatusEntrySnapshot( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + let logSnapshots = logEntries.map { entry in + SessionLogEntrySnapshot( + message: entry.message, + level: entry.level.rawValue, + source: entry.source, + timestamp: entry.timestamp.timeIntervalSince1970 + ) + } + + let progressSnapshot = progress.map { progress in + SessionProgressSnapshot(value: progress.value, label: progress.label) + } + let gitBranchSnapshot = gitBranch.map { branch in + SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty) + } + + return SessionWorkspaceSnapshot( + processTitle: processTitle, + customTitle: customTitle, + isPinned: isPinned, + currentDirectory: currentDirectory, + focusedPanelId: focusedPanelId, + layout: layout, + panels: panelSnapshots, + statusEntries: statusSnapshots, + logEntries: logSnapshots, + progress: progressSnapshot, + gitBranch: gitBranchSnapshot + ) + } + + func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) { + restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) + + let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedCurrentDirectory.isEmpty { + currentDirectory = normalizedCurrentDirectory + } + + let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) + let leafEntries = restoreSessionLayout(snapshot.layout) + var oldToNewPanelIds: [UUID: UUID] = [:] + + for entry in leafEntries { + restorePane( + entry.paneId, + snapshot: entry.snapshot, + panelSnapshotsById: panelSnapshotsById, + oldToNewPanelIds: &oldToNewPanelIds + ) + } + + pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys)) + applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot()) + + applyProcessTitle(snapshot.processTitle) + setCustomTitle(snapshot.customTitle) + isPinned = snapshot.isPinned + + statusEntries = Dictionary( + uniqueKeysWithValues: snapshot.statusEntries.map { entry in + ( + entry.key, + SidebarStatusEntry( + key: entry.key, + value: entry.value, + icon: entry.icon, + color: entry.color, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + ) + } + ) + logEntries = snapshot.logEntries.map { entry in + SidebarLogEntry( + message: entry.message, + level: SidebarLogLevel(rawValue: entry.level) ?? .info, + source: entry.source, + timestamp: Date(timeIntervalSince1970: entry.timestamp) + ) + } + progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) } + gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) } + + recomputeListeningPorts() + + if let focusedOldPanelId = snapshot.focusedPanelId, + let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId], + panels[focusedNewPanelId] != nil { + focusPanel(focusedNewPanelId) + } else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil { + focusPanel(fallbackFocusedPanelId) + } else { + scheduleFocusReconcile() + } + } + + private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot { + switch node { + case .pane(let pane): + let panelIds = sessionPanelIDs(for: pane) + let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:)) + return .pane( + SessionPaneLayoutSnapshot( + panelIds: panelIds, + selectedPanelId: selectedPanelId + ) + ) + case .split(let split): + return .split( + SessionSplitLayoutSnapshot( + orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal, + dividerPosition: split.dividerPosition, + first: sessionLayoutSnapshot(from: split.first), + second: sessionLayoutSnapshot(from: split.second) + ) + ) + } + } + + private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] { + var panelIds: [UUID] = [] + var seen = Set() + for tab in pane.tabs { + guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue } + if seen.insert(panelId).inserted { + panelIds.append(panelId) + } + } + return panelIds + } + + private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? { + guard let tabUUID = UUID(uuidString: tabIDString) else { return nil } + for (surfaceId, panelId) in surfaceIdToPanelId { + guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue } + if surfaceUUID == tabUUID { + return panelId + } + } + return nil + } + + private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? { + struct EncodedSurfaceID: Decodable { + let id: UUID + } + + guard let data = try? JSONEncoder().encode(surfaceId), + let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else { + return nil + } + return decoded.id + } + + private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? { + guard let panel = panels[panelId] else { return nil } + + let panelTitle = panelTitle(panelId: panelId) + let customTitle = panelCustomTitles[panelId] + let directory = panelDirectories[panelId] + let isPinned = pinnedPanelIds.contains(panelId) + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let branchSnapshot = panelGitBranches[panelId].map { + SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) + } + let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() + let ttyName = surfaceTTYNames[panelId] + + let terminalSnapshot: SessionTerminalPanelSnapshot? + let browserSnapshot: SessionBrowserPanelSnapshot? + switch panel.panelType { + case .terminal: + guard let terminalPanel = panel as? TerminalPanel else { return nil } + let capturedScrollback = includeScrollback + ? TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + : nil + let resolvedScrollback = terminalSnapshotScrollback( + panelId: panelId, + capturedScrollback: capturedScrollback, + includeScrollback: includeScrollback + ) + terminalSnapshot = SessionTerminalPanelSnapshot( + workingDirectory: panelDirectories[panelId], + scrollback: resolvedScrollback + ) + browserSnapshot = nil + case .browser: + guard let browserPanel = panel as? BrowserPanel else { return nil } + terminalSnapshot = nil + browserSnapshot = SessionBrowserPanelSnapshot( + urlString: browserPanel.currentURL?.absoluteString, + shouldRenderWebView: browserPanel.shouldRenderWebView, + pageZoom: Double(browserPanel.webView.pageZoom), + developerToolsVisible: browserPanel.isDeveloperToolsVisible() + ) + } + + return SessionPanelSnapshot( + id: panelId, + type: panel.panelType, + title: panelTitle, + customTitle: customTitle, + directory: directory, + isPinned: isPinned, + isManuallyUnread: isManuallyUnread, + gitBranch: branchSnapshot, + listeningPorts: listeningPorts, + ttyName: ttyName, + terminal: terminalSnapshot, + browser: browserSnapshot + ) + } + + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + + private func terminalSnapshotScrollback( + panelId: UUID, + capturedScrollback: String?, + includeScrollback: Bool + ) -> String? { + guard includeScrollback else { return nil } + let fallback = restoredTerminalScrollbackByPanelId[panelId] + let resolved = Self.resolvedSnapshotTerminalScrollback( + capturedScrollback: capturedScrollback, + fallbackScrollback: fallback + ) + if let resolved { + restoredTerminalScrollbackByPanelId[panelId] = resolved + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + } + return resolved + } + + private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] { + guard let rootPaneId = bonsplitController.allPaneIds.first else { + return [] + } + + var leaves: [SessionPaneRestoreEntry] = [] + restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves) + return leaves + } + + private func restoreSessionLayoutNode( + _ node: SessionWorkspaceLayoutSnapshot, + inPane paneId: PaneID, + leaves: inout [SessionPaneRestoreEntry] + ) { + switch node { + case .pane(let pane): + leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane)) + case .split(let split): + var anchorPanelId = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + .first + + if anchorPanelId == nil { + anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id + } + + guard let anchorPanelId, + let newSplitPanel = newTerminalSplit( + from: anchorPanelId, + orientation: split.orientation.splitOrientation, + insertFirst: false, + focus: false + ), + let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else { + leaves.append( + SessionPaneRestoreEntry( + paneId: paneId, + snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil) + ) + ) + return + } + + restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves) + restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves) + } + } + + private func restorePane( + _ paneId: PaneID, + snapshot: SessionPaneLayoutSnapshot, + panelSnapshotsById: [UUID: SessionPanelSnapshot], + oldToNewPanelIds: inout [UUID: UUID] + ) { + let existingPanelIds = bonsplitController + .tabs(inPane: paneId) + .compactMap { panelIdFromSurfaceId($0.id) } + let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil } + + var createdPanelIds: [UUID] = [] + for oldPanelId in desiredOldPanelIds { + guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue } + guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue } + createdPanelIds.append(createdPanelId) + oldToNewPanelIds[oldPanelId] = createdPanelId + } + + guard !createdPanelIds.isEmpty else { return } + + for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) { + _ = closePanel(oldPanelId, force: true) + } + + for (index, panelId) in createdPanelIds.enumerated() { + _ = reorderSurface(panelId: panelId, toIndex: index) + } + + let selectedPanelId: UUID? = { + if let selectedOldId = snapshot.selectedPanelId { + return oldToNewPanelIds[selectedOldId] + } + return createdPanelIds.first + }() + + if let selectedPanelId, + let selectedTabId = surfaceIdFromPanelId(selectedPanelId) { + bonsplitController.focusPane(paneId) + bonsplitController.selectTab(selectedTabId) + } + } + + private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? { + switch snapshot.type { + case .terminal: + let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory + let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment( + for: snapshot.terminal?.scrollback + ) + guard let terminalPanel = newTerminalSurface( + inPane: paneId, + focus: false, + workingDirectory: workingDirectory, + startupEnvironment: replayEnvironment + ) else { + return nil + } + let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback) + if let fallbackScrollback { + restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback + } else { + restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) + } + applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) + return terminalPanel.id + case .browser: + let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) } + guard let browserPanel = newBrowserSurface( + inPane: paneId, + url: initialURL, + focus: false + ) else { + return nil + } + applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) + return browserPanel.id + } + } + + private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) { + if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { + panelTitles[panelId] = title + } + + setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle) + setPanelPinned(panelId: panelId, pinned: snapshot.isPinned) + + if snapshot.isManuallyUnread { + markPanelUnread(panelId) + } else { + clearManualUnread(panelId: panelId) + } + + if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty { + updatePanelDirectory(panelId: panelId, directory: directory) + } + + if let branch = snapshot.gitBranch { + panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty) + } else { + panelGitBranches.removeValue(forKey: panelId) + } + + surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted() + + if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: panelId) + } + + if let browserSnapshot = snapshot.browser, + let browserPanel = browserPanel(for: panelId) { + let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) + if pageZoom.isFinite { + browserPanel.webView.pageZoom = pageZoom + } + + if browserSnapshot.developerToolsVisible { + _ = browserPanel.showDeveloperTools() + browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore") + } else { + _ = browserPanel.hideDeveloperTools() + } + } + } + + private func applySessionDividerPositions( + snapshotNode: SessionWorkspaceLayoutSnapshot, + liveNode: ExternalTreeNode + ) { + switch (snapshotNode, liveNode) { + case (.split(let snapshotSplit), .split(let liveSplit)): + if let splitID = UUID(uuidString: liveSplit.id) { + _ = bonsplitController.setDividerPosition( + CGFloat(snapshotSplit.dividerPosition), + forSplit: splitID, + fromExternal: true + ) + } + applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first) + applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second) + default: + return + } + } +} + enum SidebarLogLevel: String { case info case progress @@ -302,6 +783,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var listeningPorts: [Int] = [] var surfaceTTYNames: [UUID: String] = [:] + private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { @@ -995,7 +1477,12 @@ final class Workspace: Identifiable, ObservableObject { /// true = force focus/selection of the new surface, /// false = never focus (used for internal placeholder repair paths). @discardableResult - func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { + func newTerminalSurface( + inPane paneId: PaneID, + focus: Bool? = nil, + workingDirectory: String? = nil, + startupEnvironment: [String: String] = [:] + ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) // Get an existing terminal panel to inherit config from @@ -1014,6 +1501,8 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + additionalEnvironment: startupEnvironment, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel @@ -2158,6 +2647,7 @@ extension Workspace: BonsplitDelegate { manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) // Keep the workspace invariant: always retain at least one real panel. @@ -2250,6 +2740,7 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 09b18c59..cdbc0175 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -343,7 +343,18 @@ struct cmuxApp: App { .keyboardShortcut("n", modifiers: [.command, .shift]) Button("New Workspace") { - (AppDelegate.shared?.tabManager ?? tabManager).addTab() + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { +#if DEBUG + FocusLogStore.shared.append( + "cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil" + ) +#endif + appDelegate.openNewMainWindow(nil) + } + } else { + tabManager.addTab() + } } } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift new file mode 100644 index 00000000..dae9faff --- /dev/null +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -0,0 +1,214 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class AppDelegateShortcutRoutingTests: XCTestCase { + func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + XCTAssertTrue(appDelegate.focusMainWindow(windowId: firstWindowId)) + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: secondWindow.windowNumber, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not add workspace to stale active window") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window") + } + + func testAddWorkspaceInPreferredMainWindowIgnoresStaleTabManagerPointer() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // Force a stale app-level pointer to a different manager. + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + _ = appDelegate.addWorkspaceInPreferredMainWindow() + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Stale pointer must not receive menu-driven workspace creation") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Workspace creation should target key/main window context") + } + + func testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId)) +#else + XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG") +#endif + + // Ensure stale active-manager pointer does not mask routing errors. + appDelegate.tabManager = firstManager + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: secondWindow.windowNumber, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not route to another window when object-key lookup misses") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should still route by event window metadata when object-key lookup misses") + } + + func testAddWorkspaceInPreferredMainWindowUsesKeyWindowWhenObjectKeyLookupIsMismatched() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let secondWindow = window(withId: secondWindowId) else { + XCTFail("Expected both window contexts to exist") + return + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId)) +#else + XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG") +#endif + + // Stale pointer should not receive the new workspace. + appDelegate.tabManager = firstManager + + let firstCount = firstManager.tabs.count + let secondCount = secondManager.tabs.count + + _ = appDelegate.addWorkspaceInPreferredMainWindow() + + XCTAssertEqual(firstManager.tabs.count, firstCount, "Menu-driven add workspace should not route to stale window") + XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses") + } + + private func window(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(withId windowId: UUID) { + guard let window = window(withId: windowId) else { return } + window.performClose(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + } +} diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift new file mode 100644 index 00000000..fd2fff77 --- /dev/null +++ b/cmuxTests/SessionPersistenceTests.swift @@ -0,0 +1,382 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class SessionPersistenceTests: XCTestCase { + func testSaveAndLoadRoundTripWithCustomSnapshotPath() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertNotNil(loaded) + XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion) + XCTAssertEqual(loaded?.windows.count, 1) + XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs) + } + + func testLoadRejectsSchemaVersionMismatch() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL)) + + XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL)) + } + + func testDefaultSnapshotPathSanitizesBundleIdentifier() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let path = SessionPersistenceStore.defaultSnapshotFileURL( + bundleIdentifier: "com.example/unsafe id", + appSupportDirectory: tempDir + ) + + XCTAssertNotNil(path) + XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true) + } + + func testRestorePolicySkipsWhenLaunchHasExplicitArguments() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"], + environment: [:] + ) + + XCTAssertFalse(shouldRestore) + } + + func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"], + environment: [:] + ) + + XCTAssertTrue(shouldRestore) + } + + func testRestorePolicySkipsWhenRunningUnderXCTest() { + let shouldRestore = SessionRestorePolicy.shouldAttemptRestore( + arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"], + environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"] + ) + + XCTAssertFalse(shouldRestore) + } + + func testSidebarWidthSanitizationClampsToPolicyRange() { + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(-20), + SessionPersistencePolicy.minimumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(10_000), + SessionPersistencePolicy.maximumSidebarWidth, + accuracy: 0.001 + ) + XCTAssertEqual( + SessionPersistencePolicy.sanitizedSidebarWidth(nil), + SessionPersistencePolicy.defaultSidebarWidth, + accuracy: 0.001 + ) + } + + func testScrollbackReplayEnvironmentWritesReplayFile() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: "line one\nline two\n", + tempDirectory: tempDir + ) + + let path = environment[SessionScrollbackReplayStore.environmentKey] + XCTAssertNotNil(path) + XCTAssertTrue(path?.hasPrefix(tempDir.path) == true) + + guard let path else { return } + let contents = try? String(contentsOfFile: path, encoding: .utf8) + XCTAssertEqual(contents, "line one\nline two\n") + } + + func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: " \n\t ", + tempDirectory: tempDir + ) + + XCTAssertTrue(environment.isEmpty) + } + + func testScrollbackReplayEnvironmentPreservesANSIColorSequences() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let red = "\u{001B}[31m" + let reset = "\u{001B}[0m" + let source = "\(red)RED\(reset)\n" + let environment = SessionScrollbackReplayStore.replayEnvironment( + for: source, + tempDirectory: tempDir + ) + + guard let path = environment[SessionScrollbackReplayStore.environmentKey] else { + XCTFail("Expected replay file path") + return + } + + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + XCTFail("Expected replay file contents") + return + } + + XCTAssertTrue(contents.contains("\(red)RED\(reset)")) + XCTAssertTrue(contents.hasPrefix(reset)) + XCTAssertTrue(contents.hasSuffix(reset)) + } + + func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() { + let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + let source = "\u{001B}[31m" + + String(repeating: "X", count: maxChars - 7) + + "\u{001B}[0m" + + guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else { + XCTFail("Expected truncated scrollback") + return + } + + XCTAssertFalse(truncated.hasPrefix("31m")) + XCTAssertFalse(truncated.hasPrefix("[31m")) + XCTAssertFalse(truncated.hasPrefix("m")) + } + + func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() { + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"), + "/tmp/cmux-screen.txt" + ) + XCTAssertEqual( + TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "), + "/tmp/cmux-screen.txt" + ) + } + + func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() { + XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(" ")) + XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil)) + } + + func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() { + let tempRoot = URL(fileURLWithPath: "/tmp") + .appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true) + let tempFile = tempRoot + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("screen.txt", isDirectory: false) + let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt") + + XCTAssertTrue( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: tempFile, + temporaryDirectory: tempRoot + ) + ) + XCTAssertFalse( + TerminalController.shouldRemoveExportedScreenDirectory( + fileURL: outsideFile, + temporaryDirectory: tempRoot + ) + ) + } + + func testWindowUnregisterSnapshotPersistencePolicy() { + XCTAssertTrue( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false) + ) + XCTAssertFalse( + AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true) + ) + } + + func testResolvedWindowFramePrefersSavedDisplayIdentity() { + let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400) + let savedDisplay = SessionDisplaySnapshot( + displayID: 2, + frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800) + ) + + // Display 1 and 2 swapped horizontal positions between snapshot and restore. + let display1 = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800) + ) + let display2 = AppDelegate.SessionDisplayGeometry( + displayID: 2, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: savedDisplay, + availableDisplays: [display1, display2], + fallbackDisplay: display1 + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display2.visibleFrame.intersects(restored)) + XCTAssertFalse(display1.visibleFrame.intersects(restored)) + XCTAssertEqual(restored.width, 600, accuracy: 0.001) + XCTAssertEqual(restored.height, 400, accuracy: 0.001) + XCTAssertEqual(restored.minX, 200, accuracy: 0.001) + XCTAssertEqual(restored.minY, 100, accuracy: 0.001) + } + + func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() { + let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 120, accuracy: 0.001) + XCTAssertEqual(restored.minY, 80, accuracy: 0.001) + XCTAssertEqual(restored.width, 500, accuracy: 0.001) + XCTAssertEqual(restored.height, 350, accuracy: 0.001) + } + + func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() { + let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_000, height: 800), + visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800) + ) + + let restored = AppDelegate.resolvedWindowFrame( + from: savedFrame, + display: nil, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertTrue(display.visibleFrame.contains(restored)) + XCTAssertEqual(restored.minX, 50, accuracy: 0.001) + XCTAssertEqual(restored.minY, 50, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + + func testResolvedSnapshotTerminalScrollbackPrefersCaptured() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: "captured-value", + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "captured-value") + } + + func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() { + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: "fallback-value" + ) + + XCTAssertEqual(resolved, "fallback-value") + } + + func testResolvedSnapshotTerminalScrollbackTruncatesFallback() { + let oversizedFallback = String( + repeating: "x", + count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37 + ) + let resolved = Workspace.resolvedSnapshotTerminalScrollback( + capturedScrollback: nil, + fallbackScrollback: oversizedFallback + ) + + XCTAssertEqual( + resolved?.count, + SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + ) + } + + private func makeSnapshot(version: Int) -> AppSessionSnapshot { + let workspace = SessionWorkspaceSnapshot( + processTitle: "Terminal", + customTitle: "Restored", + isPinned: true, + currentDirectory: "/tmp", + focusedPanelId: nil, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)), + panels: [], + statusEntries: [], + logEntries: [], + progress: nil, + gitBranch: nil + ) + + let tabManager = SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: [workspace] + ) + + let window = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700), + display: SessionDisplaySnapshot( + displayID: 42, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200), + visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175) + ), + tabManager: tabManager, + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240) + ) + + return AppSessionSnapshot( + version: version, + createdAt: Date().timeIntervalSince1970, + windows: [window] + ) + } +} diff --git a/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py b/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py new file mode 100644 index 00000000..0f63b2b9 --- /dev/null +++ b/tests/test_session_restore_unfocused_workspace_multi_window_cycle.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +""" +Regression: unfocused workspace scrollback must persist across relaunchs in multi-window setups. +""" + +from __future__ import annotations + +import os +import plistlib +import re +import socket +import subprocess +import time +from pathlib import Path + +from cmux import cmux + + +def _bundle_id(app_path: Path) -> str: + info_path = app_path / "Contents" / "Info.plist" + if not info_path.exists(): + raise RuntimeError(f"Missing Info.plist at {info_path}") + with info_path.open("rb") as f: + info = plistlib.load(f) + bundle_id = str(info.get("CFBundleIdentifier", "")).strip() + if not bundle_id: + raise RuntimeError("Missing CFBundleIdentifier") + return bundle_id + + +def _snapshot_path(bundle_id: str) -> Path: + safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id) + return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json" + + +def _sanitize_tag_slug(raw: str) -> str: + cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower()) + cleaned = re.sub(r"-+", "-", cleaned).strip("-") + return cleaned or "agent" + + +def _socket_candidates(app_path: Path, preferred: Path) -> list[Path]: + candidates = [preferred] + app_name = app_path.stem + prefix = "cmux DEV " + if app_name.startswith(prefix): + tag = app_name[len(prefix):] + slug = _sanitize_tag_slug(tag) + candidates.append(Path(f"/tmp/cmux-debug-{slug}.sock")) + deduped: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate) + if key in seen: + continue + seen.add(key) + deduped.append(candidate) + return deduped + + +def _socket_reachable(socket_path: Path) -> bool: + if not socket_path.exists(): + return False + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(0.3) + sock.connect(str(socket_path)) + sock.sendall(b"ping\n") + data = sock.recv(1024) + return b"PONG" in data + except OSError: + return False + finally: + sock.close() + + +def _wait_for_socket(candidates: list[Path], timeout: float = 20.0) -> Path: + deadline = time.time() + timeout + while time.time() < deadline: + for candidate in candidates: + if _socket_reachable(candidate): + return candidate + time.sleep(0.2) + joined = ", ".join(str(path) for path in candidates) + raise RuntimeError(f"Socket did not become reachable: {joined}") + + +def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if not _socket_reachable(socket_path): + return + time.sleep(0.2) + raise RuntimeError(f"Socket still reachable after quit: {socket_path}") + + +def _kill_existing(app_path: Path) -> None: + exe = app_path / "Contents" / "MacOS" / "cmux DEV" + subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True) + time.sleep(1.0) + + +def _launch(app_path: Path, preferred_socket_path: Path) -> Path: + try: + preferred_socket_path.unlink() + except FileNotFoundError: + pass + subprocess.run( + [ + "open", + "-na", + str(app_path), + "--env", + f"CMUX_SOCKET_PATH={preferred_socket_path}", + "--env", + "CMUX_ALLOW_SOCKET_OVERRIDE=1", + ], + check=True, + ) + resolved_socket_path = _wait_for_socket(_socket_candidates(app_path, preferred_socket_path)) + time.sleep(1.5) + return resolved_socket_path + + +def _quit(bundle_id: str, socket_path: Path) -> None: + subprocess.run( + ["osascript", "-e", f'tell application id "{bundle_id}" to quit'], + capture_output=True, + text=True, + check=True, + ) + _wait_for_socket_closed(socket_path) + try: + socket_path.unlink() + except FileNotFoundError: + pass + time.sleep(0.8) + + +def _connect(socket_path: Path) -> cmux: + client = cmux(socket_path=str(socket_path)) + client.connect() + if not client.ping(): + raise RuntimeError("ping failed") + return client + + +def _read_scrollback(client: cmux) -> str: + return client._send_command("read_screen --scrollback") + + +def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if marker in _read_scrollback(client): + return True + time.sleep(0.25) + return False + + +def _consume_visible_markers(client: cmux, remaining: set[str], timeout: float = 4.0) -> None: + if not remaining: + return + deadline = time.time() + timeout + while time.time() < deadline and remaining: + text = _read_scrollback(client) + matched = [marker for marker in remaining if marker in text] + if matched: + for marker in matched: + remaining.discard(marker) + if not remaining: + return + time.sleep(0.25) + + +def _ensure_workspaces(client: cmux, count: int) -> None: + while len(client.list_workspaces()) < count: + client.new_workspace() + time.sleep(0.3) + + +def _list_windows(client: cmux) -> list[str]: + response = client._send_command("list_windows") + if response == "No windows": + return [] + window_ids: list[str] = [] + for line in response.splitlines(): + line = line.strip() + if not line: + continue + parts = line.lstrip("* ").split(" ", 2) + if len(parts) >= 2: + window_ids.append(parts[1]) + return window_ids + + +def _new_window(client: cmux) -> str: + response = client._send_command("new_window") + if not response.startswith("OK "): + raise RuntimeError(f"new_window failed: {response}") + return response.split(" ", 1)[1].strip() + + +def _focus_window(client: cmux, window_id: str) -> None: + response = client._send_command(f"focus_window {window_id}") + if response != "OK": + raise RuntimeError(f"focus_window failed for {window_id}: {response}") + + +def main() -> int: + app_path_str = os.environ.get("CMUX_APP_PATH", "").strip() + if not app_path_str: + print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path") + return 0 + app_path = Path(app_path_str) + if not app_path.exists(): + print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}") + return 0 + + bundle_id = _bundle_id(app_path) + snapshot = _snapshot_path(bundle_id) + # Keep the override path short enough for Darwin's Unix socket path limit. + bundle_suffix = re.sub(r"[^A-Za-z0-9]", "", bundle_id)[-16:] or "bundle" + socket_path = Path(f"/tmp/cmux-mw-restore-{bundle_suffix}.sock") + + markers = { + "w1_ws0": "CMUX_MW_RESTORE_W1_WS0", + "w1_ws1": "CMUX_MW_RESTORE_W1_WS1", + "w2_ws0": "CMUX_MW_RESTORE_W2_WS0", + "w2_ws1": "CMUX_MW_RESTORE_W2_WS1", + } + failures: list[str] = [] + + _kill_existing(app_path) + snapshot.unlink(missing_ok=True) + + try: + # Launch 1: create 2 windows x 2 workspaces; write markers. + socket_path = _launch(app_path, socket_path) + client = _connect(socket_path) + try: + # Window 1 setup. + _ensure_workspaces(client, 2) + client.select_workspace(0) + client.send(f"echo {markers['w1_ws0']}\n") + if not _wait_for_marker(client, markers["w1_ws0"]): + failures.append("missing marker for window1 workspace0 during setup") + client.select_workspace(1) + client.send(f"echo {markers['w1_ws1']}\n") + if not _wait_for_marker(client, markers["w1_ws1"]): + failures.append("missing marker for window1 workspace1 during setup") + client.select_workspace(0) # leave workspace 1 unfocused in window 1 + + # Window 2 setup. + _new_window(client) + time.sleep(0.5) + _ensure_workspaces(client, 2) + client.select_workspace(0) + client.send(f"echo {markers['w2_ws0']}\n") + if not _wait_for_marker(client, markers["w2_ws0"]): + failures.append("missing marker for window2 workspace0 during setup") + client.select_workspace(1) + client.send(f"echo {markers['w2_ws1']}\n") + if not _wait_for_marker(client, markers["w2_ws1"]): + failures.append("missing marker for window2 workspace1 during setup") + client.select_workspace(0) # leave workspace 1 unfocused in window 2 + finally: + client.close() + _quit(bundle_id, socket_path) + + # Launch 2: immediate quit without focusing unfocused workspaces. + socket_path = _launch(app_path, socket_path) + client = _connect(socket_path) + try: + window_ids = _list_windows(client) + if len(window_ids) < 2: + failures.append(f"expected >=2 windows after first relaunch, got {len(window_ids)}") + finally: + client.close() + _quit(bundle_id, socket_path) + + # Launch 3: verify all markers still present across windows/workspaces. + socket_path = _launch(app_path, socket_path) + client = _connect(socket_path) + try: + window_ids = _list_windows(client) + if len(window_ids) < 2: + failures.append(f"expected >=2 windows after second relaunch, got {len(window_ids)}") + + remaining = set(markers.values()) + for window_id in window_ids: + _focus_window(client, window_id) + time.sleep(0.3) + workspace_count = len(client.list_workspaces()) + for idx in range(min(workspace_count, 2)): + client.select_workspace(idx) + _consume_visible_markers(client, remaining, timeout=6.0) + if not remaining: + break + if not remaining: + break + + if remaining: + failures.append(f"missing markers after second relaunch: {sorted(remaining)}") + finally: + client.close() + _quit(bundle_id, socket_path) + finally: + _kill_existing(app_path) + socket_path.unlink(missing_ok=True) + snapshot.unlink(missing_ok=True) + + if failures: + print("FAIL:") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: multi-window unfocused workspaces survive repeated relaunch") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py b/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py new file mode 100644 index 00000000..87164820 --- /dev/null +++ b/tests/test_session_restore_unfocused_workspace_relaunch_cycle.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Regression: unfocused restored workspaces must survive a second relaunch. + +Repro for the historical bug: +1) Launch and save workspaces with marker scrollback. +2) Relaunch, do not focus the non-selected workspaces, then quit again. +3) Relaunch and verify marker scrollback still exists for every workspace. +""" + +from __future__ import annotations + +import os +import plistlib +import re +import socket +import subprocess +import time +from pathlib import Path + +from cmux import cmux + + +def _bundle_id(app_path: Path) -> str: + info_path = app_path / "Contents" / "Info.plist" + if not info_path.exists(): + raise RuntimeError(f"Missing Info.plist at {info_path}") + with info_path.open("rb") as f: + info = plistlib.load(f) + bundle_id = str(info.get("CFBundleIdentifier", "")).strip() + if not bundle_id: + raise RuntimeError("Missing CFBundleIdentifier") + return bundle_id + + +def _snapshot_path(bundle_id: str) -> Path: + safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id) + return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json" + + +def _socket_reachable(socket_path: Path) -> bool: + if not socket_path.exists(): + return False + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(0.3) + sock.connect(str(socket_path)) + sock.sendall(b"ping\n") + data = sock.recv(1024) + return b"PONG" in data + except OSError: + return False + finally: + sock.close() + + +def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if _socket_reachable(socket_path): + return + time.sleep(0.2) + raise RuntimeError(f"Socket did not become reachable: {socket_path}") + + +def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if not _socket_reachable(socket_path): + return + time.sleep(0.2) + raise RuntimeError(f"Socket still reachable after quit: {socket_path}") + + +def _kill_existing(app_path: Path) -> None: + exe = app_path / "Contents" / "MacOS" / "cmux DEV" + subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True) + time.sleep(1.0) + + +def _launch(app_path: Path, socket_path: Path) -> None: + try: + socket_path.unlink() + except FileNotFoundError: + pass + subprocess.run( + [ + "open", + "-na", + str(app_path), + "--env", + f"CMUX_SOCKET_PATH={socket_path}", + "--env", + "CMUX_ALLOW_SOCKET_OVERRIDE=1", + ], + check=True, + ) + _wait_for_socket(socket_path) + time.sleep(1.5) + + +def _quit(bundle_id: str, socket_path: Path) -> None: + subprocess.run( + ["osascript", "-e", f'tell application id "{bundle_id}" to quit'], + capture_output=True, + text=True, + check=True, + ) + _wait_for_socket_closed(socket_path) + try: + socket_path.unlink() + except FileNotFoundError: + pass + time.sleep(0.8) + + +def _connect(socket_path: Path) -> cmux: + client = cmux(socket_path=str(socket_path)) + client.connect() + if not client.ping(): + raise RuntimeError("ping failed") + return client + + +def _read_scrollback(client: cmux) -> str: + return client._send_command("read_screen --scrollback") + + +def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if marker in _read_scrollback(client): + return True + time.sleep(0.25) + return False + + +def main() -> int: + app_path_str = os.environ.get("CMUX_APP_PATH", "").strip() + if not app_path_str: + print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path") + return 0 + app_path = Path(app_path_str) + if not app_path.exists(): + print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}") + return 0 + + bundle_id = _bundle_id(app_path) + snapshot = _snapshot_path(bundle_id) + socket_path = Path(f"/tmp/cmux-session-restore-cycle-{bundle_id.replace('.', '-')}.sock") + + markers = [f"CMUX_RESTORE_EDGE_{i}" for i in range(3)] + failures: list[str] = [] + + _kill_existing(app_path) + snapshot.unlink(missing_ok=True) + + try: + # First launch: seed three workspaces with marker scrollback. + _launch(app_path, socket_path) + client = _connect(socket_path) + try: + while len(client.list_workspaces()) < 3: + client.new_workspace() + time.sleep(0.3) + + for idx, marker in enumerate(markers): + client.select_workspace(idx) + time.sleep(0.4) + client.send(f"echo {marker}\n") + if not _wait_for_marker(client, marker, timeout=6.0): + failures.append(f"setup marker missing in workspace {idx}: {marker}") + + # Keep selected workspace deterministic. + client.select_workspace(1) + time.sleep(0.3) + finally: + client.close() + _quit(bundle_id, socket_path) + + # Second launch: do not focus unfocused workspaces. Quit immediately. + _launch(app_path, socket_path) + client = _connect(socket_path) + try: + restored = client.list_workspaces() + if len(restored) < 3: + failures.append(f"expected >=3 workspaces after first relaunch, got {len(restored)}") + selected_indices = [idx for idx, _wid, _title, selected in restored if selected] + if selected_indices != [1]: + failures.append(f"expected selected workspace index [1], got {selected_indices}") + finally: + client.close() + _quit(bundle_id, socket_path) + + # Third launch: every workspace should still contain its marker. + _launch(app_path, socket_path) + client = _connect(socket_path) + try: + restored = client.list_workspaces() + if len(restored) < 3: + failures.append(f"expected >=3 workspaces after second relaunch, got {len(restored)}") + + for idx, marker in enumerate(markers): + client.select_workspace(idx) + if not _wait_for_marker(client, marker, timeout=8.0): + tail = "\n".join(_read_scrollback(client).splitlines()[-10:]) + failures.append( + f"workspace {idx} missing marker {marker} after second relaunch; tail:\n{tail}" + ) + finally: + client.close() + _quit(bundle_id, socket_path) + finally: + _kill_existing(app_path) + socket_path.unlink(missing_ok=True) + snapshot.unlink(missing_ok=True) + + if failures: + print("FAIL:") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: unfocused workspace scrollback survives repeated relaunch") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_shell_scrollback_restore_color_replay.py b/tests/test_shell_scrollback_restore_color_replay.py new file mode 100644 index 00000000..fed1088e --- /dev/null +++ b/tests/test_shell_scrollback_restore_color_replay.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Regression: ANSI color escape bytes in replay content must be preserved. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + if not integration_script.exists(): + print(f"SKIP: missing zsh integration script at {integration_script}") + return 0 + + base = Path("/tmp") / f"cmux_scrollback_color_replay_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + replay_file = base / "replay.bin" + replay_file.write_bytes(b"\x1b[31mRED\x1b[0m\n") + + env = dict(os.environ) + env["PATH"] = str(base / "empty-bin") + env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file) + env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script) + + result = subprocess.run( + ["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'], + env=env, + capture_output=True, + timeout=5, + ) + if result.returncode != 0: + print(f"FAIL: zsh exited non-zero rc={result.returncode}") + if result.stderr: + print(result.stderr.decode("utf-8", errors="replace").strip()) + return 1 + + output = (result.stdout or b"") + (result.stderr or b"") + if b"\x1b[31mRED\x1b[0m" not in output: + print("FAIL: ANSI color escape sequence not preserved in replay output") + return 1 + + if replay_file.exists(): + print("FAIL: replay file was not deleted after replay") + return 1 + + print("PASS: ANSI color escape sequence preserved during replay") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_shell_scrollback_restore_replay_path_regression.py b/tests/test_shell_scrollback_restore_replay_path_regression.py new file mode 100644 index 00000000..2f7d549e --- /dev/null +++ b/tests/test_shell_scrollback_restore_replay_path_regression.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Regression: scrollback replay must not depend on PATH containing coreutils. + +cmux can launch shells with PATH initially pointing at app resources. If replay +relies on bare `cat`/`rm`, startup replay silently fails before user rc files +restore PATH. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh" + if not integration_script.exists(): + print(f"SKIP: missing zsh integration script at {integration_script}") + return 0 + + base = Path("/tmp") / f"cmux_scrollback_restore_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + replay_file = base / "replay.txt" + replay_file.write_text("scrollback-line-1\nscrollback-line-2\n", encoding="utf-8") + + env = dict(os.environ) + env["PATH"] = str(base / "empty-bin") + env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file) + env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script) + + result = subprocess.run( + ["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'], + env=env, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + print(f"FAIL: zsh exited non-zero rc={result.returncode}") + if result.stderr.strip(): + print(result.stderr.strip()) + return 1 + + output = (result.stdout or "") + (result.stderr or "") + if "scrollback-line-1" not in output or "scrollback-line-2" not in output: + print("FAIL: replay text was not printed during integration startup") + return 1 + + if replay_file.exists(): + print("FAIL: replay file was not deleted after replay") + return 1 + + print("PASS: scrollback replay works with minimal PATH") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) From 2c1fd1f8019e90de6bab47f4fb07ea1b43582f65 Mon Sep 17 00:00:00 2001 From: sugakoji Date: Sun, 22 Feb 2026 22:54:52 +0900 Subject: [PATCH 002/136] Add open wrapper to route URLs to embedded browser When running `open https://...` inside a cmux terminal, the URL now opens in the built-in browser panel instead of the system default browser. Non-URL arguments and explicit flags pass through to /usr/bin/open unchanged. Closes #306 Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 174623776eb0baef04f5a9ab49b926427c149acd) --- GhosttyTabs.xcodeproj/project.pbxproj | 3 ++ Resources/bin/open | 66 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100755 Resources/bin/open diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 65cc12e6..58641e08 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; @@ -96,6 +97,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, ); name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; @@ -182,6 +184,7 @@ A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; + D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; diff --git a/Resources/bin/open b/Resources/bin/open new file mode 100755 index 00000000..4000fed6 --- /dev/null +++ b/Resources/bin/open @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser +# +# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper +# intercepts `open https://...` invocations and opens them in cmux's built-in +# browser within the same workspace. All other arguments pass through to +# /usr/bin/open unchanged. + +# Pass through immediately if not in a cmux terminal. +if [[ -z "$CMUX_SOCKET_PATH" ]]; then + exec /usr/bin/open "$@" +fi + +# No arguments → pass through. +if [[ $# -eq 0 ]]; then + exec /usr/bin/open "$@" +fi + +# Scan for flags that indicate explicit user intent → pass through. +# Also collect non-flag arguments (potential URLs/files). +passthrough=false +urls=() +for arg in "$@"; do + case "$arg" in + -a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr) + passthrough=true + break + ;; + -*) + # Unknown flag → be conservative, pass through + passthrough=true + break + ;; + http://*|https://*) + urls+=("$arg") + ;; + *) + # Non-URL, non-flag argument (file path, etc.) → pass through all + passthrough=true + break + ;; + esac +done + +if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then + exec /usr/bin/open "$@" +fi + +# Find cmux CLI (same directory as this script). +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +CMUX_CLI="$SELF_DIR/cmux" + +if [[ ! -x "$CMUX_CLI" ]]; then + exec /usr/bin/open "$@" +fi + +# Open each URL in cmux's in-app browser. +failed=false +for url in "${urls[@]}"; do + "$CMUX_CLI" browser open "$url" 2>/dev/null || failed=true +done + +# If any failed, fall back to system open for all URLs. +if [[ "$failed" == true ]]; then + exec /usr/bin/open "$@" +fi From f104dbc37f8caf7ec2fffe5be19a4b16e4f4067a Mon Sep 17 00:00:00 2001 From: sugakoji Date: Sun, 22 Feb 2026 23:21:58 +0900 Subject: [PATCH 003/136] Fix double-open on partial failure with multiple URLs When multiple URLs were passed and some succeeded but others failed, the fallback re-opened all URLs via /usr/bin/open, causing duplicates. Now only failed URLs are passed to the system open fallback. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 3790b0c0f0b98286b78f6f5aa8dbc9756cf756e8) --- Resources/bin/open | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index 4000fed6..b161e8b7 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -54,13 +54,13 @@ if [[ ! -x "$CMUX_CLI" ]]; then exec /usr/bin/open "$@" fi -# Open each URL in cmux's in-app browser. -failed=false +# Open each URL in cmux's in-app browser; track failures individually. +failed_urls=() for url in "${urls[@]}"; do - "$CMUX_CLI" browser open "$url" 2>/dev/null || failed=true + "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done -# If any failed, fall back to system open for all URLs. -if [[ "$failed" == true ]]; then - exec /usr/bin/open "$@" +# Fall back to system open only for URLs that failed. +if [[ ${#failed_urls[@]} -gt 0 ]]; then + exec /usr/bin/open "${failed_urls[@]}" fi From 2428ae5dbd5c6e3804f06b7d95e721e770adf14b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:13:14 -0800 Subject: [PATCH 004/136] Respect browser link settings in open wrapper --- Resources/bin/open | 150 +++++++++++++++++++- Sources/GhosttyTerminalView.swift | 3 + tests/test_open_wrapper.py | 221 ++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+), 5 deletions(-) create mode 100755 tests/test_open_wrapper.py diff --git a/Resources/bin/open b/Resources/bin/open index b161e8b7..e08a87c1 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -6,14 +6,135 @@ # browser within the same workspace. All other arguments pass through to # /usr/bin/open unchanged. +SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}" +DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}" + +if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then + SYSTEM_OPEN_BIN="/usr/bin/open" +fi + +if [[ ! -x "$DEFAULTS_BIN" ]]; then + DEFAULTS_BIN="/usr/bin/defaults" +fi + +settings_domain="${CMUX_BUNDLE_ID:-}" +whitelist_raw="" +whitelist_patterns=() + +system_open() { + exec "$SYSTEM_OPEN_BIN" "$@" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +normalize_host() { + local value + value="$(trim "$1")" + value="${value,,}" + [[ -z "$value" ]] && return 1 + + if [[ "$value" == *"://"* ]]; then + value="${value#*://}" + fi + + value="${value%%/*}" + value="${value%%\?*}" + value="${value%%\#*}" + + if [[ "$value" == *"@"* ]]; then + value="${value##*@}" + fi + + if [[ "$value" == \[* ]]; then + value="${value#\[}" + value="${value%%\]*}" + elif [[ "$value" == *:* ]]; then + local colons="${value//[^:]}" + if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then + value="${value%:*}" + fi + fi + + while [[ "$value" == .* ]]; do + value="${value#.}" + done + while [[ "$value" == *. ]]; do + value="${value%.}" + done + + [[ -z "$value" ]] && return 1 + printf '%s' "$value" +} + +normalize_whitelist_pattern() { + local value + value="$(trim "$1")" + value="${value,,}" + [[ -z "$value" ]] && return 1 + + if [[ "$value" == \*.* ]]; then + local suffix + suffix="$(normalize_host "${value#*.}")" || return 1 + printf '*.%s' "$suffix" + return 0 + fi + + normalize_host "$value" +} + +host_matches_pattern() { + local host="$1" + local pattern="$2" + + if [[ "$pattern" == \*.* ]]; then + local suffix="${pattern#*.}" + [[ "$host" == "$suffix" ]] && return 0 + [[ "$host" == *".$suffix" ]] && return 0 + return 1 + fi + + [[ "$host" == "$pattern" ]] +} + +host_matches_whitelist() { + local url="$1" + if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + return 0 + fi + + local host + host="$(normalize_host "$url")" || return 1 + for pattern in "${whitelist_patterns[@]}"; do + if host_matches_pattern "$host" "$pattern"; then + return 0 + fi + done + return 1 +} + +load_whitelist_patterns() { + local raw="$1" + local line + while IFS= read -r line || [[ -n "$line" ]]; do + local normalized + normalized="$(normalize_whitelist_pattern "$line")" || continue + whitelist_patterns+=("$normalized") + done <<< "$raw" +} + # Pass through immediately if not in a cmux terminal. if [[ -z "$CMUX_SOCKET_PATH" ]]; then - exec /usr/bin/open "$@" + system_open "$@" fi # No arguments → pass through. if [[ $# -eq 0 ]]; then - exec /usr/bin/open "$@" + system_open "$@" fi # Scan for flags that indicate explicit user intent → pass through. @@ -43,7 +164,22 @@ for arg in "$@"; do done if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then - exec /usr/bin/open "$@" + system_open "$@" +fi + +# Respect the same settings used for terminal link clicks. +if [[ -n "$settings_domain" ]]; then + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + case "${open_in_cmux,,}" in + 0|false|no) + system_open "$@" + ;; + esac + + whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" + if [[ -n "$whitelist_raw" ]]; then + load_whitelist_patterns "$whitelist_raw" + fi fi # Find cmux CLI (same directory as this script). @@ -51,16 +187,20 @@ SELF_DIR="$(cd "$(dirname "$0")" && pwd)" CMUX_CLI="$SELF_DIR/cmux" if [[ ! -x "$CMUX_CLI" ]]; then - exec /usr/bin/open "$@" + system_open "$@" fi # Open each URL in cmux's in-app browser; track failures individually. failed_urls=() for url in "${urls[@]}"; do + if ! host_matches_whitelist "$url"; then + failed_urls+=("$url") + continue + fi "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done # Fall back to system open only for URLs that failed. if [[ ${#failed_urls[@]} -gt 0 ]]; then - exec /usr/bin/open "${failed_urls[@]}" + system_open "${failed_urls[@]}" fi diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1bd23f8d..3c3fa1a0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1365,6 +1365,9 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { + env["CMUX_BUNDLE_ID"] = bundleId + } // Port range for this workspace (base/range snapshotted once per app session) do { diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py new file mode 100755 index 00000000..c4d90c27 --- /dev/null +++ b/tests/test_open_wrapper.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +Regression tests for Resources/bin/open. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "open" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_log(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def run_wrapper( + *, + args: list[str], + open_setting: str | None, + whitelist: str | None, + fail_urls: list[str] | None = None, +) -> tuple[list[str], list[str], int, str]: + with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td: + tmp = Path(td) + wrapper = tmp / "open" + shutil.copy2(SOURCE_WRAPPER, wrapper) + wrapper.chmod(0o755) + + open_log = tmp / "open.log" + cmux_log = tmp / "cmux.log" + system_open = tmp / "system-open" + defaults = tmp / "defaults" + cmux = tmp / "cmux" + + make_executable( + system_open, + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$FAKE_OPEN_LOG" +""", + ) + + make_executable( + defaults, + """#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" != "read" ]]; then + exit 1 +fi +key="${3:-}" +case "$key" in + browserOpenTerminalLinksInCmuxBrowser) + if [[ -v FAKE_DEFAULTS_OPEN ]]; then + printf '%s\\n' "$FAKE_DEFAULTS_OPEN" + exit 0 + fi + exit 1 + ;; + browserHostWhitelist) + if [[ -v FAKE_DEFAULTS_WHITELIST ]]; then + printf '%s' "$FAKE_DEFAULTS_WHITELIST" + exit 0 + fi + exit 1 + ;; + *) + exit 1 + ;; +esac +""", + ) + + make_executable( + cmux, + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG" +url="${*: -1}" +if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then + IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS" + for fail_url in "${failures[@]}"; do + if [[ "$url" == "$fail_url" ]]; then + exit 1 + fi + done +fi +exit 0 +""", + ) + + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock" + env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test" + env["CMUX_OPEN_WRAPPER_SYSTEM_OPEN"] = str(system_open) + env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults) + env["FAKE_OPEN_LOG"] = str(open_log) + env["FAKE_CMUX_LOG"] = str(cmux_log) + + if open_setting is None: + env.pop("FAKE_DEFAULTS_OPEN", None) + else: + env["FAKE_DEFAULTS_OPEN"] = open_setting + + if whitelist is None: + env.pop("FAKE_DEFAULTS_WHITELIST", None) + else: + env["FAKE_DEFAULTS_WHITELIST"] = whitelist + + if fail_urls: + env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls) + else: + env.pop("FAKE_CMUX_FAIL_URLS", None) + + result = subprocess.run( + [str(wrapper), *args], + env=env, + capture_output=True, + text=True, + check=False, + ) + + return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip() + + +def expect(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) + + +def test_toggle_disabled_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + open_setting="0", + whitelist="", + ) + expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"toggle off: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures) + + +def test_whitelist_miss_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + open_setting="1", + whitelist="localhost\n127.0.0.1", + ) + expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"whitelist miss: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"whitelist miss: expected system open [{url}], got {open_log}", failures) + + +def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: + url = "https://api.example.com/path?q=1" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + open_setting="1", + whitelist="*.example.com", + ) + expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"whitelist match: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures) + + +def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None: + good = "https://api.example.com" + failed = "https://fail.example.com" + external = "https://outside.test" + open_log, cmux_log, code, stderr = run_wrapper( + args=[good, failed, external], + open_setting="1", + whitelist="*.example.com", + fail_urls=[failed], + ) + expect(code == 0, f"partial failure: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {good}", f"browser open {failed}"], + f"partial failure: cmux log mismatch {cmux_log}", + failures, + ) + expect( + open_log == [f"{failed} {external}"], + f"partial failure: expected fallback for failed/external only, got {open_log}", + failures, + ) + + +def main() -> int: + failures: list[str] = [] + test_toggle_disabled_passthrough(failures) + test_whitelist_miss_passthrough(failures) + test_whitelist_match_routes_to_cmux(failures) + test_partial_failures_only_fallback_failed_urls(failures) + + if failures: + print("open wrapper regression tests failed:") + for failure in failures: + print(f" - {failure}") + return 1 + + print("open wrapper regression tests passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0046b674aa4928a08f830d49db75892d173442ff Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:26:07 -0800 Subject: [PATCH 005/136] Split open-wrapper interception into its own setting --- Resources/bin/open | 6 ++- Sources/Panels/BrowserPanel.swift | 16 +++++++ Sources/cmuxApp.swift | 18 ++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 20 ++++++++ tests/test_open_wrapper.py | 47 +++++++++++++++---- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index e08a87c1..f4104d90 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -169,7 +169,11 @@ fi # Respect the same settings used for terminal link clicks. if [[ -n "$settings_domain" ]]; then - open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)" + if [[ -z "$open_in_cmux" ]]; then + # Backward compatibility for installs that predate the dedicated open-wrapper toggle. + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + fi case "${open_in_cmux,,}" in 0|false|no) system_open "$@" diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index ee3452d2..534b6edb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" + static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true + static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" @@ -137,6 +140,19 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { + return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) + } + + // Migrate existing behavior for users who only had the link-click toggle. + if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil { + return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) + } + + return defaultInterceptTerminalOpenCommandInCmuxBrowser + } + static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist return raw diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 1d4f57fb..47c22d8d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2559,6 +2559,8 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @@ -3023,13 +3025,24 @@ struct SettingsView: View { .controlSize(.small) } - if openTerminalLinksInCmuxBrowser { + SettingsCardDivider() + + SettingsCardRow( + "Intercept open http(s) in Terminal", + subtitle: "When off, `open https://...` and `open http://...` always use your default browser." + ) { + Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { SettingsCardDivider() VStack(alignment: .leading, spacing: 6) { SettingsCardRow( "Hosts to Open in Embedded Browser", - subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux." + subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." ) { EmptyView() } @@ -3291,6 +3304,7 @@ struct SettingsView: View { browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8355569e..e3b604f5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4140,6 +4140,26 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } } final class TerminalOpenURLTargetResolutionTests: XCTestCase { diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index c4d90c27..e602eca7 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -30,7 +30,8 @@ def read_log(path: Path) -> list[str]: def run_wrapper( *, args: list[str], - open_setting: str | None, + intercept_setting: str | None, + legacy_open_setting: str | None = None, whitelist: str | None, fail_urls: list[str] | None = None, ) -> tuple[list[str], list[str], int, str]: @@ -63,9 +64,16 @@ if [[ "${1:-}" != "read" ]]; then fi key="${3:-}" case "$key" in + browserInterceptTerminalOpenCommandInCmuxBrowser) + if [[ -v FAKE_DEFAULTS_INTERCEPT_OPEN ]]; then + printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN" + exit 0 + fi + exit 1 + ;; browserOpenTerminalLinksInCmuxBrowser) - if [[ -v FAKE_DEFAULTS_OPEN ]]; then - printf '%s\\n' "$FAKE_DEFAULTS_OPEN" + if [[ -v FAKE_DEFAULTS_LEGACY_OPEN ]]; then + printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN" exit 0 fi exit 1 @@ -110,10 +118,15 @@ exit 0 env["FAKE_OPEN_LOG"] = str(open_log) env["FAKE_CMUX_LOG"] = str(cmux_log) - if open_setting is None: - env.pop("FAKE_DEFAULTS_OPEN", None) + if intercept_setting is None: + env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None) else: - env["FAKE_DEFAULTS_OPEN"] = open_setting + env["FAKE_DEFAULTS_INTERCEPT_OPEN"] = intercept_setting + + if legacy_open_setting is None: + env.pop("FAKE_DEFAULTS_LEGACY_OPEN", None) + else: + env["FAKE_DEFAULTS_LEGACY_OPEN"] = legacy_open_setting if whitelist is None: env.pop("FAKE_DEFAULTS_WHITELIST", None) @@ -145,7 +158,7 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( args=[url], - open_setting="0", + intercept_setting="0", whitelist="", ) expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures) @@ -157,7 +170,7 @@ def test_whitelist_miss_passthrough(failures: list[str]) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( args=[url], - open_setting="1", + intercept_setting="1", whitelist="localhost\n127.0.0.1", ) expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures) @@ -169,7 +182,7 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: url = "https://api.example.com/path?q=1" open_log, cmux_log, code, stderr = run_wrapper( args=[url], - open_setting="1", + intercept_setting="1", whitelist="*.example.com", ) expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures) @@ -183,7 +196,7 @@ def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None external = "https://outside.test" open_log, cmux_log, code, stderr = run_wrapper( args=[good, failed, external], - open_setting="1", + intercept_setting="1", whitelist="*.example.com", fail_urls=[failed], ) @@ -200,12 +213,26 @@ def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None ) +def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting=None, + legacy_open_setting="0", + whitelist="", + ) + expect(code == 0, f"legacy fallback: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"legacy fallback: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures) + + def main() -> int: failures: list[str] = [] test_toggle_disabled_passthrough(failures) test_whitelist_miss_passthrough(failures) test_whitelist_match_routes_to_cmux(failures) test_partial_failures_only_fallback_failed_urls(failures) + test_legacy_toggle_fallback_passthrough(failures) if failures: print("open wrapper regression tests failed:") From 3afa345f3a45bda781a22a8ff67c22ead74ccdf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:38:37 -0800 Subject: [PATCH 006/136] Harden open wrapper for Bash 3 and IDN host parity --- Resources/bin/open | 99 +++++++++++++++++++++++++++++++++----- tests/test_open_wrapper.py | 95 ++++++++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index f4104d90..9c81ea54 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -8,6 +8,7 @@ SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}" DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}" +PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}" if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then SYSTEM_OPEN_BIN="/usr/bin/open" @@ -17,6 +18,14 @@ if [[ ! -x "$DEFAULTS_BIN" ]]; then DEFAULTS_BIN="/usr/bin/defaults" fi +if [[ -n "$PYTHON3_BIN" ]]; then + if [[ ! -x "$PYTHON3_BIN" ]]; then + PYTHON3_BIN="" + fi +elif command -v python3 >/dev/null 2>&1; then + PYTHON3_BIN="$(command -v python3)" +fi + settings_domain="${CMUX_BUNDLE_ID:-}" whitelist_raw="" whitelist_patterns=() @@ -32,10 +41,74 @@ trim() { printf '%s' "$value" } +to_lower_ascii() { + # Bash 3.2-compatible lowercase conversion. + LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +normalize_boolean() { + to_lower_ascii "$(trim "$1")" +} + +is_false_setting() { + local normalized + normalized="$(normalize_boolean "$1")" + case "$normalized" in + 0|false|no|off) + return 0 + ;; + esac + return 1 +} + +canonicalize_idn_host() { + local value="$1" + [[ -z "$PYTHON3_BIN" ]] && { + printf '%s' "$value" + return 0 + } + + local canonicalized + canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true +import sys + +host = sys.argv[1].strip().rstrip(".") +if not host: + raise SystemExit(1) + +labels = host.split(".") +if any(not label for label in labels): + raise SystemExit(1) + +try: + canonical = ".".join(label.encode("idna").decode("ascii") for label in labels) +except Exception: + raise SystemExit(1) + +sys.stdout.write(canonical.lower()) +PY +)" + if [[ -n "$canonicalized" ]]; then + printf '%s' "$canonicalized" + return 0 + fi + printf '%s' "$value" +} + +is_http_url() { + local value="$1" + case "$value" in + [Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*) + return 0 + ;; + esac + return 1 +} + normalize_host() { local value value="$(trim "$1")" - value="${value,,}" + value="$(to_lower_ascii "$value")" [[ -z "$value" ]] && return 1 if [[ "$value" == *"://"* ]]; then @@ -68,13 +141,14 @@ normalize_host() { done [[ -z "$value" ]] && return 1 + value="$(canonicalize_idn_host "$value")" printf '%s' "$value" } normalize_whitelist_pattern() { local value value="$(trim "$1")" - value="${value,,}" + value="$(to_lower_ascii "$value")" [[ -z "$value" ]] && return 1 if [[ "$value" == \*.* ]]; then @@ -152,13 +226,14 @@ for arg in "$@"; do passthrough=true break ;; - http://*|https://*) - urls+=("$arg") - ;; *) - # Non-URL, non-flag argument (file path, etc.) → pass through all - passthrough=true - break + if is_http_url "$arg"; then + urls+=("$arg") + else + # Non-URL, non-flag argument (file path, etc.) → pass through all + passthrough=true + break + fi ;; esac done @@ -174,11 +249,9 @@ if [[ -n "$settings_domain" ]]; then # Backward compatibility for installs that predate the dedicated open-wrapper toggle. open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" fi - case "${open_in_cmux,,}" in - 0|false|no) - system_open "$@" - ;; - esac + if is_false_setting "$open_in_cmux"; then + system_open "$@" + fi whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" if [[ -n "$whitelist_raw" ]]; then diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index e602eca7..6119033a 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -65,21 +65,21 @@ fi key="${3:-}" case "$key" in browserInterceptTerminalOpenCommandInCmuxBrowser) - if [[ -v FAKE_DEFAULTS_INTERCEPT_OPEN ]]; then + if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN" exit 0 fi exit 1 ;; browserOpenTerminalLinksInCmuxBrowser) - if [[ -v FAKE_DEFAULTS_LEGACY_OPEN ]]; then + if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN" exit 0 fi exit 1 ;; browserHostWhitelist) - if [[ -v FAKE_DEFAULTS_WHITELIST ]]; then + if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; then printf '%s' "$FAKE_DEFAULTS_WHITELIST" exit 0 fi @@ -97,7 +97,10 @@ esac """#!/usr/bin/env bash set -euo pipefail printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG" -url="${*: -1}" +url="" +for arg in "$@"; do + url="$arg" +done if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS" for fail_url in "${failures[@]}"; do @@ -139,7 +142,7 @@ exit 0 env.pop("FAKE_CMUX_FAIL_URLS", None) result = subprocess.run( - [str(wrapper), *args], + ["/bin/bash", str(wrapper), *args], env=env, capture_output=True, text=True, @@ -166,6 +169,26 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None: expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures) +def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting=" FaLsE ", + whitelist="", + ) + expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"toggle off (case-insensitive): expected system open [{url}], got {open_log}", + failures, + ) + + def test_whitelist_miss_passthrough(failures: list[str]) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( @@ -226,13 +249,75 @@ def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None: expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures) +def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting=None, + legacy_open_setting=" Off ", + whitelist="", + ) + expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"legacy fallback (case-insensitive): expected system open [{url}], got {open_log}", + failures, + ) + + +def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None: + url = "HTTPS://api.example.com/path?q=1" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="*.example.com", + ) + expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures) + + +def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: + url = "https://xn--bcher-kva.example/path" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="bücher.example", + ) + expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures) + + +def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None: + url = "https://bücher.example/path" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="xn--bcher-kva.example", + ) + expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures) + + def main() -> int: failures: list[str] = [] test_toggle_disabled_passthrough(failures) + test_toggle_disabled_case_insensitive_passthrough(failures) test_whitelist_miss_passthrough(failures) test_whitelist_match_routes_to_cmux(failures) test_partial_failures_only_fallback_failed_urls(failures) test_legacy_toggle_fallback_passthrough(failures) + test_legacy_toggle_fallback_case_insensitive_passthrough(failures) + test_uppercase_scheme_routes_to_cmux(failures) + test_unicode_whitelist_matches_punycode_url(failures) + test_punycode_whitelist_matches_unicode_url(failures) if failures: print("open wrapper regression tests failed:") From 4ee6640e35b1dce0678f79ca2ba6b5286e5564f1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:50:01 -0800 Subject: [PATCH 007/136] Preserve terminal focus for non-focus split opens --- Sources/Workspace.swift | 26 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 60 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb0345d4..ab06df65 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -960,6 +960,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id + let previousFocusedPanelId = focusedPanelId // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, // so we can hand it to focusPanel as the "move focus FROM" view. @@ -989,7 +990,17 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView?.clearSuppressReparentFocus() } } else { - scheduleFocusReconcile() + // Bonsplit focuses the newly-created pane by default; restore the caller's + // pre-split focus context when this split is explicitly non-focus-intent. + if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { + previousHostedView?.suppressReparentFocus() + focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + scheduleFocusReconcile() + } } return newPanel @@ -1089,6 +1100,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id + let previousFocusedPanelId = focusedPanelId // Create the split with the browser tab already present. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. @@ -1110,7 +1122,17 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView?.clearSuppressReparentFocus() } } else { - scheduleFocusReconcile() + // Bonsplit focuses the newly-created pane by default; restore the caller's + // pre-split focus context when this split is explicitly non-focus-intent. + if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { + previousHostedView?.suppressReparentFocus() + focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + scheduleFocusReconcile() + } } installBrowserPanelSubscription(browserPanel) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e3b604f5..b24947bf 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1800,6 +1800,66 @@ final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { @MainActor final class WorkspacePanelGitBranchTests: XCTestCase { + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser split to preserve pre-split focus" + ) + } + + func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let terminalSplitPanel = workspace.newTerminalSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected terminal split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal split to preserve pre-split focus" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { From 0dbe95b797d9d7d6fe3a66145e4a1b45459bca78 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:04:57 -0800 Subject: [PATCH 008/136] Reassert non-focus split focus after delayed callbacks --- Sources/Workspace.swift | 189 +++++++++++------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 54 +++++ 2 files changed, 175 insertions(+), 68 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ab06df65..99bdd74e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -962,49 +962,43 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = newPanel.id let previousFocusedPanelId = focusedPanelId - // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, - // so we can hand it to focusPanel as the "move focus FROM" view. - let previousHostedView = focusedTerminalPanel?.hostedView + // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, + // so we can hand it to focusPanel as the "move focus FROM" view. + let previousHostedView = focusedTerminalPanel?.hostedView - // Create the split with the new tab already present in the new pane. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) - return nil - } + // Create the split with the new tab already present in the new pane. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + return nil + } #if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") + dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") #endif - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - // Bonsplit focuses the newly-created pane by default; restore the caller's - // pre-split focus context when this split is explicitly non-focus-intent. - if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { - previousHostedView?.suppressReparentFocus() - focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - scheduleFocusReconcile() - } - } + // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. + // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, + // stealing focus from the new panel and creating model/surface divergence. + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) + } - return newPanel - } + return newPanel + } /// Create a new surface (nested tab) in the specified pane with a terminal panel. /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), @@ -1102,38 +1096,32 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = browserPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the browser tab already present. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) - return nil - } + // Create the split with the browser tab already present. + // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) + return nil + } - // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. - let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - // Bonsplit focuses the newly-created pane by default; restore the caller's - // pre-split focus context when this split is explicitly non-focus-intent. - if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { - previousHostedView?.suppressReparentFocus() - focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - scheduleFocusReconcile() - } - } + // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) + } installBrowserPanelSubscription(browserPanel) @@ -1559,6 +1547,71 @@ final class Workspace: Identifiable, ObservableObject { } // MARK: - Focus Management + private func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + scheduleFocusReconcile() + return + } + + // Bonsplit splitPane focuses the newly created pane and may emit one delayed + // didSelect/didFocus callback. Re-assert focus over multiple turns so model + // focus and AppKit first responder stay aligned with non-focus-intent splits. + reassertFocusAfterNonFocusSplit( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: true + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + self.scheduleFocusReconcile() + } + } + } + + private func reassertFocusAfterNonFocusSplit( + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard panels[preferredPanelId] != nil else { return } + + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + return + } + + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return + } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + } + func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index b24947bf..137ede9e 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1860,6 +1860,60 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected focused pane for initial panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), + let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), + let splitTab = workspace.bonsplitController + .tabs(inPane: splitPaneId) + .first(where: { $0.id == splitTabId }) else { + XCTFail("Expected split pane/tab mapping") + return + } + + // Simulate one delayed stale split-selection callback from bonsplit. + DispatchQueue.main.async { + workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus split to reassert the pre-split focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.focusedPaneId, + originalPaneId, + "Expected focused pane to converge back to the pre-split pane" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to converge back to the pre-split focused panel" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { From a369cf44195f5022e177fcf524e258c22d6132cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:13:15 -0800 Subject: [PATCH 009/136] Prevent background webview autofocus from stealing focus --- Sources/Panels/BrowserPanelView.swift | 14 ++++++++ Sources/Panels/CmuxWebView.swift | 8 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 37f0af44..98e311e3 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3112,6 +3112,11 @@ struct WebViewRepresentable: NSViewRepresentable { let webView = panel.webView context.coordinator.panel = panel context.coordinator.webView = webView + Self.applyWebViewFirstResponderPolicy( + panel: panel, + webView: webView, + isPanelFocused: isPanelFocused + ) let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() if shouldUseWindowPortal { @@ -3359,6 +3364,15 @@ struct WebViewRepresentable: NSViewRepresentable { } } + private static func applyWebViewFirstResponderPolicy( + panel: BrowserPanel, + webView: WKWebView, + isPanelFocused: Bool + ) { + guard let cmuxWebView = webView as? CmuxWebView else { return } + cmuxWebView.allowsFirstResponderAcquisition = isPanelFocused && !panel.shouldSuppressWebViewFocus() + } + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachRetryWorkItem?.cancel() coordinator.attachRetryWorkItem = nil diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 7ee2d00a..93f5a321 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -22,6 +22,14 @@ final class CmuxWebView: WKWebView { var onContextMenuDownloadStateChanged: ((Bool) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? + /// Guard against background panes stealing first responder (e.g. page autofocus). + /// BrowserPanelView updates this as pane focus state changes. + var allowsFirstResponderAcquisition: Bool = true + + override func becomeFirstResponder() -> Bool { + guard allowsFirstResponderAcquisition else { return false } + return super.becomeFirstResponder() + } override func performKeyEquivalent(with event: NSEvent) -> Bool { // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 137ede9e..d536fed5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -99,6 +99,39 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertTrue(spy.invoked) } + @MainActor + func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(webView)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(webView.becomeFirstResponder()) + + _ = window.makeFirstResponder(webView) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView)) + } + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() From 4fd669d76e6b73c61e06118689f30f3d1339fd46 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:34:07 -0800 Subject: [PATCH 010/136] Guard NSWindow responder against unfocused webview descendants --- Sources/AppDelegate.swift | 82 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 40 +++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a270f941..f3ab91f3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -278,6 +278,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.cmux_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -397,7 +407,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() - installWindowKeyEquivalentSwizzle() + installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() installShortcutMonitor() installShortcutDefaultsObserver() @@ -1533,8 +1543,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func installWindowKeyEquivalentSwizzle() { + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + } + + private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle } private func installShortcutMonitor() { @@ -3833,6 +3849,21 @@ enum MenuBarIconRenderer { private extension NSWindow { + @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if let responder, + let webView = Self.cmuxOwningWebView(for: responder), + !webView.allowsFirstResponderAcquisition { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + return cmux_makeFirstResponder(responder) + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -3941,4 +3972,51 @@ private extension NSWindow { parts.append("'\(chars)'(\(event.keyCode))") return parts.joined(separator: "+") } + + private static func cmuxOwningWebView(for responder: NSResponder) -> CmuxWebView? { + if let webView = responder as? CmuxWebView { + return webView + } + + if let view = responder as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let webView = cmuxOwningWebView(for: delegateView) { + return webView + } + + var current = responder.nextResponder + while let next = current { + if let webView = next as? CmuxWebView { + return webView + } + if let view = next as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { + if let webView = view as? CmuxWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? CmuxWebView { + return webView + } + current = candidate.superview + } + + return nil + } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d536fed5..9b0980fa 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -63,6 +63,10 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + private final class FirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } + } + func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "n", modifiers: [.command]) @@ -132,6 +136,42 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + @MainActor + func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(descendant)) + + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView)) + } + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() From 6f44f17f33399b77661e56c359adf74cc3965cbd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:55:06 -0800 Subject: [PATCH 011/136] Fix follow-up PR331 settings and split focus regressions --- Sources/Panels/BrowserPanel.swift | 4 + Sources/Workspace.swift | 89 ++++++++++++++++++- Sources/cmuxApp.swift | 2 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 37 ++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 534b6edb..2a683ba5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -153,6 +153,10 @@ enum BrowserLinkOpenSettings { return defaultInterceptTerminalOpenCommandInCmuxBrowser } + static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool { + interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults) + } + static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist return raw diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 99bdd74e..d8532cc4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -488,6 +488,14 @@ final class Workspace: Identifiable, ObservableObject { private var focusReconcileScheduled = false private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false + private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? + private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 + + private struct PendingNonFocusSplitFocusReassert { + let generation: UInt64 + let preferredPanelId: UUID + let splitPanelId: UUID + } struct DetachedSurfaceTransfer { let panelId: UUID @@ -1553,14 +1561,21 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView: GhosttySurfaceScrollView? ) { guard let preferredPanelId, panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert() scheduleFocusReconcile() return } + let generation = beginNonFocusSplitFocusReassert( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + // Bonsplit splitPane focuses the newly created pane and may emit one delayed // didSelect/didFocus callback. Re-assert focus over multiple turns so model // focus and AppKit first responder stay aligned with non-focus-intent splits. reassertFocusAfterNonFocusSplit( + generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, @@ -1570,6 +1585,7 @@ final class Workspace: Identifiable, ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } self.reassertFocusAfterNonFocusSplit( + generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, @@ -1579,23 +1595,37 @@ final class Workspace: Identifiable, ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } self.reassertFocusAfterNonFocusSplit( + generation: generation, preferredPanelId: preferredPanelId, splitPanelId: splitPanelId, previousHostedView: previousHostedView, allowPreviousHostedView: false ) self.scheduleFocusReconcile() + self.clearNonFocusSplitFocusReassert(generation: generation) } } } private func reassertFocusAfterNonFocusSplit( + generation: UInt64, preferredPanelId: UUID, splitPanelId: UUID, previousHostedView: GhosttySurfaceScrollView?, allowPreviousHostedView: Bool ) { - guard panels[preferredPanelId] != nil else { return } + guard matchesPendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) else { + return + } + + guard panels[preferredPanelId] != nil else { + clearNonFocusSplitFocusReassert(generation: generation) + return + } if focusedPanelId == splitPanelId { focusPanel( @@ -1613,6 +1643,7 @@ final class Workspace: Identifiable, ObservableObject { } func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") @@ -2054,6 +2085,11 @@ extension Workspace: BonsplitDelegate { let panel = panels[panelId] else { return } + + if shouldTreatCurrentEventAsExplicitFocusIntent() { + markExplicitFocusIntent(on: panelId) + } + syncPinnedStateForTab(selectedTabId, panelId: panelId) syncUnreadBadgeStateForPanel(panelId) @@ -2105,6 +2141,57 @@ extension Workspace: BonsplitDelegate { ) } + private func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + private func matchesPendingNonFocusSplitFocusReassert( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID + ) -> Bool { + guard let pending = pendingNonFocusSplitFocusReassert else { return false } + return pending.generation == generation && + pending.preferredPanelId == preferredPanelId && + pending.splitPanelId == splitPanelId + } + + private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { + guard let pending = pendingNonFocusSplitFocusReassert else { return } + if let generation, pending.generation != generation { return } + pendingNonFocusSplitFocusReassert = nil + } + + private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool { + guard let eventType = NSApp.currentEvent?.type else { return false } + switch eventType { + case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel, + .gesture, .magnify, .rotate, .swipe: + return true + default: + return false + } + } + + private func markExplicitFocusIntent(on panelId: UUID) { + guard let pending = pendingNonFocusSplitFocusReassert, + pending.splitPanelId == panelId else { + return + } + pendingNonFocusSplitFocusReassert = nil + } + func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { func recordPostCloseSelection() { let tabs = controller.tabs(inPane: pane) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 47c22d8d..7ed81980 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2560,7 +2560,7 @@ struct SettingsView: View { @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) - private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser + private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9b0980fa..89b59080 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1987,6 +1987,35 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testBrowserSplitWithFocusFalseAllowsSubsequentExplicitFocusOnSplitPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + workspace.focusPanel(browserSplitPanel.id) + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + browserSplitPanel.id, + "Expected explicit focus intent to keep the split panel focused" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { @@ -4347,6 +4376,14 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) } + + func testSettingsInitialOpenCommandInterceptionValueFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: defaults)) + } } final class TerminalOpenURLTargetResolutionTests: XCTestCase { From 283b8983077de715548c7dbccc55a72e57e0be5a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:57:46 -0800 Subject: [PATCH 012/136] Match bonsplit border styling to Ghostty split divider color --- Sources/Workspace.swift | 64 +++++++++++++++++++++++++++++++++++------ vendor/bonsplit | 2 +- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 99bdd74e..79be6461 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -329,7 +329,10 @@ final class Workspace: Identifiable, ObservableObject { } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance(from: config.backgroundColor) + bonsplitAppearance( + from: config.backgroundColor, + splitDividerColor: config.resolvedSplitDividerColor + ) } private static func usesDarkChrome( @@ -347,25 +350,70 @@ final class Workspace: Identifiable, ObservableObject { return backgroundColor.hexString() } - private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - let backgroundHex = resolvedChromeBackgroundHex(from: backgroundColor) + private static func resolvedChromeBorderColor( + from backgroundColor: NSColor, + splitDividerColor: NSColor? + ) -> NSColor { + if let splitDividerColor { + return splitDividerColor + } + let isLightBackground = backgroundColor.isLightColor + return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4) + } + + private static func resolvedChromeColors( + from backgroundColor: NSColor, + splitDividerColor: NSColor? = nil + ) -> BonsplitConfiguration.Appearance.ChromeColors { + guard let backgroundHex = resolvedChromeBackgroundHex(from: backgroundColor) else { + return .init() + } + let borderHex = resolvedChromeBorderColor( + from: backgroundColor, + splitDividerColor: splitDividerColor + ).hexString() + return .init(backgroundHex: backgroundHex, borderHex: borderHex) + } + + private static func bonsplitAppearance( + from backgroundColor: NSColor, + splitDividerColor: NSColor? = nil + ) -> BonsplitConfiguration.Appearance { + let chromeColors = resolvedChromeColors( + from: backgroundColor, + splitDividerColor: splitDividerColor + ) return BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: .init(backgroundHex: backgroundHex) + chromeColors: chromeColors ) } func applyGhosttyChrome(from config: GhosttyConfig) { - applyGhosttyChrome(backgroundColor: config.backgroundColor) + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + splitDividerColor: config.resolvedSplitDividerColor + ) } func applyGhosttyChrome(backgroundColor: NSColor) { - let nextHex = Self.resolvedChromeBackgroundHex(from: backgroundColor) - if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex { + applyGhosttyChrome(backgroundColor: backgroundColor, splitDividerColor: nil) + } + + private func applyGhosttyChrome( + backgroundColor: NSColor, + splitDividerColor: NSColor? + ) { + let nextChromeColors = Self.resolvedChromeColors( + from: backgroundColor, + splitDividerColor: splitDividerColor + ) + if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextChromeColors.backgroundHex && + bonsplitController.configuration.appearance.chromeColors.borderHex == nextChromeColors.borderHex { return } - bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + bonsplitController.configuration.appearance.chromeColors = nextChromeColors } init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { diff --git a/vendor/bonsplit b/vendor/bonsplit index 0dd965a7..6cdbea4f 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 0dd965a75f02f7a358f87fd607a9e2034450a79c +Subproject commit 6cdbea4f9051517d292d0e859ae71427e04e7fde From d31cbb7123bdf30925958275a7a5d95c9df18812 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:01:37 -0800 Subject: [PATCH 013/136] Use gray separator borders and drop active-tab bottom border --- Sources/Workspace.swift | 52 ++++++----------------------------------- vendor/bonsplit | 2 +- 2 files changed, 8 insertions(+), 46 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 79be6461..e6a5d83f 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -329,10 +329,7 @@ final class Workspace: Identifiable, ObservableObject { } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance( - from: config.backgroundColor, - splitDividerColor: config.resolvedSplitDividerColor - ) + bonsplitAppearance(from: config.backgroundColor) } private static func usesDarkChrome( @@ -350,39 +347,17 @@ final class Workspace: Identifiable, ObservableObject { return backgroundColor.hexString() } - private static func resolvedChromeBorderColor( - from backgroundColor: NSColor, - splitDividerColor: NSColor? - ) -> NSColor { - if let splitDividerColor { - return splitDividerColor - } - let isLightBackground = backgroundColor.isLightColor - return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4) - } - private static func resolvedChromeColors( - from backgroundColor: NSColor, - splitDividerColor: NSColor? = nil + from backgroundColor: NSColor ) -> BonsplitConfiguration.Appearance.ChromeColors { guard let backgroundHex = resolvedChromeBackgroundHex(from: backgroundColor) else { return .init() } - let borderHex = resolvedChromeBorderColor( - from: backgroundColor, - splitDividerColor: splitDividerColor - ).hexString() - return .init(backgroundHex: backgroundHex, borderHex: borderHex) + return .init(backgroundHex: backgroundHex) } - private static func bonsplitAppearance( - from backgroundColor: NSColor, - splitDividerColor: NSColor? = nil - ) -> BonsplitConfiguration.Appearance { - let chromeColors = resolvedChromeColors( - from: backgroundColor, - splitDividerColor: splitDividerColor - ) + private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { + let chromeColors = resolvedChromeColors(from: backgroundColor) return BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, @@ -391,24 +366,11 @@ final class Workspace: Identifiable, ObservableObject { } func applyGhosttyChrome(from config: GhosttyConfig) { - applyGhosttyChrome( - backgroundColor: config.backgroundColor, - splitDividerColor: config.resolvedSplitDividerColor - ) + applyGhosttyChrome(backgroundColor: config.backgroundColor) } func applyGhosttyChrome(backgroundColor: NSColor) { - applyGhosttyChrome(backgroundColor: backgroundColor, splitDividerColor: nil) - } - - private func applyGhosttyChrome( - backgroundColor: NSColor, - splitDividerColor: NSColor? - ) { - let nextChromeColors = Self.resolvedChromeColors( - from: backgroundColor, - splitDividerColor: splitDividerColor - ) + let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextChromeColors.backgroundHex && bonsplitController.configuration.appearance.chromeColors.borderHex == nextChromeColors.borderHex { return diff --git a/vendor/bonsplit b/vendor/bonsplit index 6cdbea4f..f3761ac6 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 6cdbea4f9051517d292d0e859ae71427e04e7fde +Subproject commit f3761ac696294c3aa85a2e2e5d28e3c37477524e From 4e172abb66958148bc85121d6191feec9fd288aa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:03:39 -0800 Subject: [PATCH 014/136] Update bonsplit for selected-tab separator fix --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index f3761ac6..3758476b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f3761ac696294c3aa85a2e2e5d28e3c37477524e +Subproject commit 3758476b10f85fe4df24bc8ee27e892ea7867c25 From 881ebb2386507f152d295b4f6873e7f6594edaca Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:07:22 -0800 Subject: [PATCH 015/136] Update bonsplit for selected-tab separator gap --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 3758476b..ad6ca8c3 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 3758476b10f85fe4df24bc8ee27e892ea7867c25 +Subproject commit ad6ca8c3de070f10e7ac8f2941ddb3bcbc023820 From 42f1933c80f617167968b25acce4bdae63662134 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:21:54 -0800 Subject: [PATCH 016/136] Update bonsplit submodule to fix Release build (#341) Picks up manaflow-ai/bonsplit#8 which moves ThemedSplitView outside #if DEBUG so it compiles in Release configuration (fixes nightly). --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index ad6ca8c3..c9186860 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ad6ca8c3de070f10e7ac8f2941ddb3bcbc023820 +Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d From e90b45f75eccb30a1088007b59bf646451732dc4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:48:13 -0800 Subject: [PATCH 017/136] Match bonsplit chrome to Ghostty theme --- Sources/Workspace.swift | 22 ++-------------------- cmuxTests/GhosttyConfigTests.swift | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 572fbc57..6c8d20cc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -332,28 +332,10 @@ final class Workspace: Identifiable, ObservableObject { bonsplitAppearance(from: config.backgroundColor) } - private static func usesDarkChrome( - appAppearance: NSAppearance? = NSApp?.effectiveAppearance - ) -> Bool { - guard let appAppearance else { return false } - return appAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - } - - private static func resolvedChromeBackgroundHex( - from backgroundColor: NSColor, - appAppearance: NSAppearance? = NSApp?.effectiveAppearance - ) -> String? { - guard usesDarkChrome(appAppearance: appAppearance) else { return nil } - return backgroundColor.hexString() - } - - private static func resolvedChromeColors( + nonisolated static func resolvedChromeColors( from backgroundColor: NSColor ) -> BonsplitConfiguration.Appearance.ChromeColors { - guard let backgroundHex = resolvedChromeBackgroundHex(from: backgroundColor) else { - return .init() - } - return .init(backgroundHex: backgroundHex) + .init(backgroundHex: backgroundColor.hexString()) } private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index effff6ad..25946b38 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -208,6 +208,30 @@ final class GhosttyConfigTests: XCTestCase { } } +final class WorkspaceChromeThemeTests: XCTestCase { + func testResolvedChromeColorsUsesLightGhosttyBackground() { + guard let backgroundColor = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test color") + return + } + + let colors = Workspace.resolvedChromeColors(from: backgroundColor) + XCTAssertEqual(colors.backgroundHex, "#FDF6E3") + XCTAssertNil(colors.borderHex) + } + + func testResolvedChromeColorsUsesDarkGhosttyBackground() { + guard let backgroundColor = NSColor(hex: "#272822") else { + XCTFail("Expected valid test color") + return + } + + let colors = Workspace.resolvedChromeColors(from: backgroundColor) + XCTAssertEqual(colors.backgroundHex, "#272822") + XCTAssertNil(colors.borderHex) + } +} + final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01) From 963bb03e99e7380cfa86225e3fb36074df74274e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:06:39 -0800 Subject: [PATCH 018/136] Refactor workspace theme refresh to shared resolver --- Sources/WorkspaceContentView.swift | 22 +++++++++++---- cmuxTests/GhosttyConfigTests.swift | 45 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 4ba398be..ad1d48d2 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -9,7 +9,7 @@ struct WorkspaceContentView: View { let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool let workspacePortalPriority: Int - @State private var config = GhosttyConfig.load() + @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig() @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -87,7 +87,7 @@ struct WorkspaceContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncBonsplitNotificationBadges() - workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) + refreshGhosttyAppearanceConfig() } .onChange(of: notificationStore.notifications) { _, _ in syncBonsplitNotificationBadges() @@ -104,9 +104,9 @@ struct WorkspaceContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { - workspace.applyGhosttyChrome(backgroundColor: backgroundColor) + refreshGhosttyAppearanceConfig(backgroundOverride: backgroundColor) } else { - workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) + refreshGhosttyAppearanceConfig() } } } @@ -141,8 +141,18 @@ struct WorkspaceContentView: View { } } - private func refreshGhosttyAppearanceConfig() { - let next = GhosttyConfig.load() + static func resolveGhosttyAppearanceConfig( + backgroundOverride: NSColor? = nil, + loadConfig: () -> GhosttyConfig = GhosttyConfig.load, + defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor } + ) -> GhosttyConfig { + var next = loadConfig() + next.backgroundColor = backgroundOverride ?? defaultBackground() + return next + } + + private func refreshGhosttyAppearanceConfig(backgroundOverride: NSColor? = nil) { + let next = Self.resolveGhosttyAppearanceConfig(backgroundOverride: backgroundOverride) config = next workspace.applyGhosttyChrome(from: next) } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 25946b38..2e28bf5f 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -232,6 +232,51 @@ final class WorkspaceChromeThemeTests: XCTestCase { } } +final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { + func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() { + guard let loadedBackground = NSColor(hex: "#112233"), + let runtimeBackground = NSColor(hex: "#FDF6E3"), + let loadedForeground = NSColor(hex: "#ABCDEF") else { + XCTFail("Expected valid test colors") + return + } + + var loaded = GhosttyConfig() + loaded.backgroundColor = loadedBackground + loaded.foregroundColor = loadedForeground + loaded.unfocusedSplitOpacity = 0.42 + + let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig( + loadConfig: { loaded }, + defaultBackground: { runtimeBackground } + ) + + XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3") + XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF") + XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001) + } + + func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() { + guard let loadedBackground = NSColor(hex: "#112233"), + let runtimeBackground = NSColor(hex: "#FDF6E3"), + let explicitOverride = NSColor(hex: "#272822") else { + XCTFail("Expected valid test colors") + return + } + + var loaded = GhosttyConfig() + loaded.backgroundColor = loadedBackground + + let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig( + backgroundOverride: explicitOverride, + loadConfig: { loaded }, + defaultBackground: { runtimeBackground } + ) + + XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822") + } +} + final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01) From 8f68ddb947edcfa1f25b49b36fa3075ea5cbfbec Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:14:10 -0800 Subject: [PATCH 019/136] Add filesystem logs for theme resolution flow --- Sources/GhosttyTerminalView.swift | 25 +++++++++++++- Sources/Workspace.swift | 15 +++++++-- Sources/WorkspaceContentView.swift | 54 ++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3c3fa1a0..ed21b275 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -166,10 +166,33 @@ class GhosttyApp { private(set) var config: ghostty_config_t? private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 + private static func resolveBackgroundLogURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> URL { + if let explicitPath = environment["CMUX_DEBUG_BG_LOG"], + !explicitPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return URL(fileURLWithPath: explicitPath) + } + + if let debugLogPath = environment["CMUX_DEBUG_LOG"], + !debugLogPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let baseURL = URL(fileURLWithPath: debugLogPath) + let extensionSeparatorIndex = baseURL.lastPathComponent.lastIndex(of: ".") + let stem = extensionSeparatorIndex.map { String(baseURL.lastPathComponent[..<$0]) } ?? baseURL.lastPathComponent + let bgName = "\(stem)-bg.log" + return baseURL.deletingLastPathComponent().appendingPathComponent(bgName) + } + + return URL(fileURLWithPath: "/tmp/cmux-bg.log") + } + let backgroundLogEnabled = { if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" { return true } + if ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"] != nil { + return true + } if ProcessInfo.processInfo.environment["GHOSTTYTABS_DEBUG_BG"] == "1" { return true } @@ -178,7 +201,7 @@ class GhosttyApp { } return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") }() - private let backgroundLogURL = URL(fileURLWithPath: "/tmp/cmux-bg.log") + private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() private var appObservers: [NSObjectProtocol] = [] // Scroll lag tracking diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6c8d20cc..99d17dcf 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -352,9 +352,20 @@ final class Workspace: Identifiable, ObservableObject { } func applyGhosttyChrome(backgroundColor: NSColor) { + let currentChromeColors = bonsplitController.configuration.appearance.chromeColors let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) - if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextChromeColors.backgroundHex && - bonsplitController.configuration.appearance.chromeColors.borderHex == nextChromeColors.borderHex { + let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && + currentChromeColors.borderHex == nextChromeColors.borderHex + + if GhosttyApp.shared.backgroundLogEnabled { + let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" + let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" + GhosttyApp.shared.logBackground( + "theme apply workspace=\(id.uuidString) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" + ) + } + + if isNoOp { return } bonsplitController.configuration.appearance.chromeColors = nextChromeColors diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ad1d48d2..ebe6a414 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -9,7 +9,7 @@ struct WorkspaceContentView: View { let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool let workspacePortalPriority: Int - @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig() + @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -87,7 +87,7 @@ struct WorkspaceContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncBonsplitNotificationBadges() - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "onAppear") } .onChange(of: notificationStore.notifications) { _, _ in syncBonsplitNotificationBadges() @@ -96,17 +96,20 @@ struct WorkspaceContentView: View { syncBonsplitNotificationBadges() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload") } - .onChange(of: colorScheme) { _, _ in + .onChange(of: colorScheme) { oldValue, newValue in // Keep split overlay color/opacity in sync with light/dark theme transitions. - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)") } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { - refreshGhosttyAppearanceConfig(backgroundOverride: backgroundColor) + refreshGhosttyAppearanceConfig( + reason: "ghosttyDefaultBackgroundDidChange:withPayload", + backgroundOverride: backgroundColor + ) } else { - refreshGhosttyAppearanceConfig() + refreshGhosttyAppearanceConfig(reason: "ghosttyDefaultBackgroundDidChange:withoutPayload") } } } @@ -142,20 +145,51 @@ struct WorkspaceContentView: View { } static func resolveGhosttyAppearanceConfig( + reason: String = "unspecified", backgroundOverride: NSColor? = nil, loadConfig: () -> GhosttyConfig = GhosttyConfig.load, defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor } ) -> GhosttyConfig { var next = loadConfig() - next.backgroundColor = backgroundOverride ?? defaultBackground() + let loadedBackgroundHex = next.backgroundColor.hexString() + let defaultBackgroundHex: String + let resolvedBackground: NSColor + + if let backgroundOverride { + resolvedBackground = backgroundOverride + defaultBackgroundHex = "skipped" + } else { + let fallback = defaultBackground() + resolvedBackground = fallback + defaultBackgroundHex = fallback.hexString() + } + + next.backgroundColor = resolvedBackground + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")" + ) + } return next } - private func refreshGhosttyAppearanceConfig(backgroundOverride: NSColor? = nil) { - let next = Self.resolveGhosttyAppearanceConfig(backgroundOverride: backgroundOverride) + private func refreshGhosttyAppearanceConfig(reason: String, backgroundOverride: NSColor? = nil) { + let previousBackgroundHex = config.backgroundColor.hexString() + let next = Self.resolveGhosttyAppearanceConfig( + reason: reason, + backgroundOverride: backgroundOverride + ) + logTheme( + "theme refresh workspace=\(workspace.id.uuidString) reason=\(reason) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" + ) config = next workspace.applyGhosttyChrome(from: next) } + + private func logTheme(_ message: String) { + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground(message) + } } extension WorkspaceContentView { From 790f3c8287f0a4bfa7375c6820f07279bbcd9d60 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:20:12 -0800 Subject: [PATCH 020/136] Ignore stale background payload in theme refresh --- Sources/WorkspaceContentView.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ebe6a414..1ab7655f 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -103,14 +103,13 @@ struct WorkspaceContentView: View { refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)") } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in - if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { - refreshGhosttyAppearanceConfig( - reason: "ghosttyDefaultBackgroundDidChange:withPayload", - backgroundOverride: backgroundColor - ) - } else { - refreshGhosttyAppearanceConfig(reason: "ghosttyDefaultBackgroundDidChange:withoutPayload") - } + let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + // Payload ordering can lag across rapid config/theme updates. + // Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned + // with Ghostty's current runtime theme. + refreshGhosttyAppearanceConfig( + reason: "ghosttyDefaultBackgroundDidChange:payload=\(payloadHex)" + ) } } From afba0fb4597f185f8ff319d5a5afa17762a42fc8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 00:29:16 -0800 Subject: [PATCH 021/136] Coalesce Ghostty background notifications to latest value --- Sources/GhosttyTerminalView.swift | 63 ++++++++++++++++++------- cmuxTests/GhosttyConfigTests.swift | 75 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 16 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ed21b275..af2a94a4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -124,6 +124,48 @@ enum TerminalOpenURLTarget: Equatable { } } +/// Coalesces Ghostty background notifications so consumers only observe +/// the latest runtime background for a burst of updates. +final class GhosttyDefaultBackgroundNotificationDispatcher { + private let coalescer: NotificationBurstCoalescer + private let postNotification: ([AnyHashable: Any]) -> Void + private var pendingUserInfo: [AnyHashable: Any]? + + init( + delay: TimeInterval = 1.0 / 30.0, + postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: userInfo + ) + } + ) { + coalescer = NotificationBurstCoalescer(delay: delay) + self.postNotification = postNotification + } + + func signal(backgroundColor: NSColor, opacity: Double) { + let signalOnMain = { [self] in + pendingUserInfo = [ + GhosttyNotificationKey.backgroundColor: backgroundColor, + GhosttyNotificationKey.backgroundOpacity: opacity + ] + coalescer.signal { [self] in + guard let userInfo = pendingUserInfo else { return } + pendingUserInfo = nil + postNotification(userInfo) + } + } + + if Thread.isMainThread { + signalOnMain() + } else { + DispatchQueue.main.async(execute: signalOnMain) + } + } +} + func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -203,6 +245,7 @@ class GhosttyApp { }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() private var appObservers: [NSObjectProtocol] = [] + private let defaultBackgroundNotificationDispatcher = GhosttyDefaultBackgroundNotificationDispatcher() // Scroll lag tracking private(set) var isScrolling = false @@ -631,22 +674,10 @@ class GhosttyApp { } private func notifyDefaultBackgroundDidChange() { - let userInfo: [AnyHashable: Any] = [ - GhosttyNotificationKey.backgroundColor: defaultBackgroundColor, - GhosttyNotificationKey.backgroundOpacity: defaultBackgroundOpacity - ] - let post = { - NotificationCenter.default.post( - name: .ghosttyDefaultBackgroundDidChange, - object: nil, - userInfo: userInfo - ) - } - if Thread.isMainThread { - post() - } else { - DispatchQueue.main.async(execute: post) - } + defaultBackgroundNotificationDispatcher.signal( + backgroundColor: defaultBackgroundColor, + opacity: defaultBackgroundOpacity + ) } private func performOnMain(_ work: @MainActor () -> T) -> T { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 2e28bf5f..80a5d1f4 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -340,6 +340,81 @@ final class NotificationBurstCoalescerTests: XCTestCase { } } +final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { + func testSignalCoalescesBurstToLatestBackground() { + guard let dark = NSColor(hex: "#272822"), + let light = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test colors") + return + } + + let expectation = expectation(description: "coalesced notification") + expectation.expectedFulfillmentCount = 1 + var postedUserInfos: [[AnyHashable: Any]] = [] + + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(delay: 0.01) { userInfo in + postedUserInfos.append(userInfo) + expectation.fulfill() + } + + DispatchQueue.main.async { + dispatcher.signal(backgroundColor: dark, opacity: 0.95) + dispatcher.signal(backgroundColor: light, opacity: 0.75) + } + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(postedUserInfos.count, 1) + XCTAssertEqual( + (postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(), + "#FDF6E3" + ) + XCTAssertEqual( + postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]), + 0.75, + accuracy: 0.0001 + ) + } + + func testSignalAcrossSeparateBurstsPostsMultipleNotifications() { + guard let dark = NSColor(hex: "#272822"), + let light = NSColor(hex: "#FDF6E3") else { + XCTFail("Expected valid test colors") + return + } + + let expectation = expectation(description: "two notifications") + expectation.expectedFulfillmentCount = 2 + var postedHexes: [String] = [] + + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(delay: 0.01) { userInfo in + let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + postedHexes.append(hex) + expectation.fulfill() + } + + DispatchQueue.main.async { + dispatcher.signal(backgroundColor: dark, opacity: 1.0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + dispatcher.signal(backgroundColor: light, opacity: 1.0) + } + } + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"]) + } + + private func postedOpacity(from value: Any?) -> Double { + if let value = value as? Double { + return value + } + if let value = value as? NSNumber { + return value.doubleValue + } + XCTFail("Expected background opacity payload") + return -1 + } +} + final class RecentlyClosedBrowserStackTests: XCTestCase { func testPopReturnsEntriesInLIFOOrder() { var stack = RecentlyClosedBrowserStack(capacity: 20) From cd570dbab22ebd432936202c42c579fcb4c6170c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:03:16 -0800 Subject: [PATCH 022/136] Unify runtime theme reload path and prioritize surface background updates --- Sources/AppDelegate.swift | 5 + Sources/GhosttyTerminalView.swift | 285 ++++++++++++++++++++++++----- Sources/WorkspaceContentView.swift | 4 +- Sources/cmuxApp.swift | 2 +- cmuxTests/GhosttyConfigTests.swift | 73 ++++++-- 5 files changed, 307 insertions(+), 62 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f3ab91f3..7bafbc8e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1857,6 +1857,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if normalizedFlags == [.command], chars == "q" { return handleQuitShortcutWarning() } + if normalizedFlags == [.command, .shift], + (chars == "," || chars == "<" || event.keyCode == 43) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") + return true + } // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept key events — let them flow through to the input method. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index af2a94a4..2b80a7f3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -124,15 +124,33 @@ enum TerminalOpenURLTarget: Equatable { } } +enum GhosttyDefaultBackgroundUpdateScope: Int { + case unscoped = 0 + case app = 1 + case surface = 2 + + var logLabel: String { + switch self { + case .unscoped: return "unscoped" + case .app: return "app" + case .surface: return "surface" + } + } +} + /// Coalesces Ghostty background notifications so consumers only observe /// the latest runtime background for a burst of updates. final class GhosttyDefaultBackgroundNotificationDispatcher { private let coalescer: NotificationBurstCoalescer private let postNotification: ([AnyHashable: Any]) -> Void private var pendingUserInfo: [AnyHashable: Any]? + private var pendingEventId: UInt64 = 0 + private var pendingSource: String = "unspecified" + private let logEvent: ((String) -> Void)? init( delay: TimeInterval = 1.0 / 30.0, + logEvent: ((String) -> Void)? = nil, postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in NotificationCenter.default.post( name: .ghosttyDefaultBackgroundDidChange, @@ -142,18 +160,29 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { } ) { coalescer = NotificationBurstCoalescer(delay: delay) + self.logEvent = logEvent self.postNotification = postNotification } - func signal(backgroundColor: NSColor, opacity: Double) { + func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) { let signalOnMain = { [self] in + pendingEventId = eventId + pendingSource = source pendingUserInfo = [ GhosttyNotificationKey.backgroundColor: backgroundColor, - GhosttyNotificationKey.backgroundOpacity: opacity + GhosttyNotificationKey.backgroundOpacity: opacity, + GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId), + GhosttyNotificationKey.backgroundSource: source ] + logEvent?( + "bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))" + ) coalescer.signal { [self] in guard let userInfo = pendingUserInfo else { return } + let eventId = pendingEventId + let source = pendingSource pendingUserInfo = nil + logEvent?("bg notify flushed id=\(eventId) source=\(source)") postNotification(userInfo) } } @@ -203,6 +232,11 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? class GhosttyApp { static let shared = GhosttyApp() + private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() private(set) var app: ghostty_app_t? private(set) var config: ghostty_config_t? @@ -245,7 +279,14 @@ class GhosttyApp { }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() private var appObservers: [NSObjectProtocol] = [] - private let defaultBackgroundNotificationDispatcher = GhosttyDefaultBackgroundNotificationDispatcher() + private var backgroundEventCounter: UInt64 = 0 + private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped + private var defaultBackgroundScopeSource: String = "initialize" + private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = + GhosttyDefaultBackgroundNotificationDispatcher(logEvent: { [weak self] message in + guard let self, self.backgroundLogEnabled else { return } + self.logBackground(message) + }) // Scroll lag tracking private(set) var isScrolling = false @@ -361,7 +402,7 @@ class GhosttyApp { // Load default config (includes user config). If this fails hard (e.g. due to // invalid user config), ghostty_app_new may return nil; we fall back below. loadDefaultConfigFilesWithLegacyFallback(primaryConfig) - updateDefaultBackground(from: primaryConfig) + updateDefaultBackground(from: primaryConfig, source: "initialize.primaryConfig") // Create runtime config with callbacks var runtimeConfig = ghostty_runtime_config_s() @@ -483,7 +524,7 @@ class GhosttyApp { } ghostty_config_finalize(fallbackConfig) - updateDefaultBackground(from: fallbackConfig) + updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig") guard let created = ghostty_app_new(&runtimeConfig, fallbackConfig) else { #if DEBUG @@ -543,6 +584,13 @@ class GhosttyApp { return true } + static func shouldApplyDefaultBackgroundUpdate( + currentScope: GhosttyDefaultBackgroundUpdateScope, + incomingScope: GhosttyDefaultBackgroundUpdateScope + ) -> Bool { + incomingScope.rawValue >= currentScope.rawValue + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -590,18 +638,31 @@ class GhosttyApp { } } - func reloadConfiguration(soft: Bool = false) { - guard let app else { return } + func reloadConfiguration(soft: Bool = false, source: String = "unspecified") { + guard let app else { + logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") + return + } + logThemeAction("reload begin source=\(source) soft=\(soft)") + resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { ghostty_app_update_config(app, config) NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) + logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return } - guard let newConfig = ghostty_config_new() else { return } + guard let newConfig = ghostty_config_new() else { + logThemeAction("reload skipped source=\(source) soft=\(soft) reason=config_alloc_failed") + return + } loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_app_update_config(app, newConfig) - updateDefaultBackground(from: newConfig) + updateDefaultBackground( + from: newConfig, + source: "reloadConfiguration(source=\(source))", + scope: .unscoped + ) DispatchQueue.main.async { self.applyBackgroundToKeyWindow() } @@ -610,18 +671,7 @@ class GhosttyApp { } config = newConfig NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) - } - - func reloadConfiguration(for surface: ghostty_surface_t, soft: Bool = false) { - if soft, let config { - ghostty_surface_update_config(surface, config) - return - } - - guard let newConfig = ghostty_config_new() else { return } - loadDefaultConfigFilesWithLegacyFallback(newConfig) - ghostty_surface_update_config(surface, newConfig) - ghostty_config_free(newConfig) + logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } func openConfigurationInTextEdit() { @@ -643,15 +693,30 @@ class GhosttyApp { return String(decoding: buffer, as: UTF8.self) } - private func updateDefaultBackground(from config: ghostty_config_t?) { - guard let config else { return } - let previousHex = defaultBackgroundColor.hexString() - let previousOpacity = defaultBackgroundOpacity + private func resetDefaultBackgroundUpdateScope(source: String) { + let previousScope = defaultBackgroundUpdateScope + let previousScopeSource = defaultBackgroundScopeSource + defaultBackgroundUpdateScope = .unscoped + defaultBackgroundScopeSource = "reset:\(source)" + if backgroundLogEnabled { + logBackground( + "default background scope reset source=\(source) previousScope=\(previousScope.logLabel) previousSource=\(previousScopeSource)" + ) + } + } + private func updateDefaultBackground( + from config: ghostty_config_t?, + source: String, + scope: GhosttyDefaultBackgroundUpdateScope = .unscoped + ) { + guard let config else { return } + + var resolvedColor = defaultBackgroundColor var color = ghostty_config_color_s() let bgKey = "background" if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) { - defaultBackgroundColor = NSColor( + resolvedColor = NSColor( red: CGFloat(color.r) / 255, green: CGFloat(color.g) / 255, blue: CGFloat(color.b) / 255, @@ -659,24 +724,99 @@ class GhosttyApp { ) } - var opacity: Double = 1.0 + var opacity = defaultBackgroundOpacity let opacityKey = "background-opacity" _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) + applyDefaultBackground( + color: resolvedColor, + opacity: opacity, + source: source, + scope: scope + ) + } + + private func applyDefaultBackground( + color: NSColor, + opacity: Double, + source: String, + scope: GhosttyDefaultBackgroundUpdateScope + ) { + let previousScope = defaultBackgroundUpdateScope + let previousScopeSource = defaultBackgroundScopeSource + guard Self.shouldApplyDefaultBackgroundUpdate(currentScope: previousScope, incomingScope: scope) else { + if backgroundLogEnabled { + logBackground( + "default background skipped source=\(source) incomingScope=\(scope.logLabel) currentScope=\(previousScope.logLabel) currentSource=\(previousScopeSource) color=\(color.hexString()) opacity=\(String(format: "%.3f", opacity))" + ) + } + return + } + + defaultBackgroundUpdateScope = scope + defaultBackgroundScopeSource = source + + let previousHex = defaultBackgroundColor.hexString() + let previousOpacity = defaultBackgroundOpacity + defaultBackgroundColor = color defaultBackgroundOpacity = opacity let hasChanged = previousHex != defaultBackgroundColor.hexString() || abs(previousOpacity - defaultBackgroundOpacity) > 0.0001 if hasChanged { - notifyDefaultBackgroundDidChange() + notifyDefaultBackgroundDidChange(source: source) } if backgroundLogEnabled { - logBackground("default background updated color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") + logBackground( + "default background updated source=\(source) scope=\(scope.logLabel) previousScope=\(previousScope.logLabel) previousScopeSource=\(previousScopeSource) previousColor=\(previousHex) previousOpacity=\(String(format: "%.3f", previousOpacity)) color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) changed=\(hasChanged)" + ) } } - private func notifyDefaultBackgroundDidChange() { - defaultBackgroundNotificationDispatcher.signal( - backgroundColor: defaultBackgroundColor, - opacity: defaultBackgroundOpacity + private func nextBackgroundEventId() -> UInt64 { + precondition(Thread.isMainThread, "Background event IDs must be generated on main thread") + backgroundEventCounter &+= 1 + return backgroundEventCounter + } + + private func notifyDefaultBackgroundDidChange(source: String) { + let signal = { [self] in + let eventId = nextBackgroundEventId() + defaultBackgroundNotificationDispatcher.signal( + backgroundColor: defaultBackgroundColor, + opacity: defaultBackgroundOpacity, + eventId: eventId, + source: source + ) + } + if Thread.isMainThread { + signal() + } else { + DispatchQueue.main.async(execute: signal) + } + } + + private func logThemeAction(_ message: String) { + guard backgroundLogEnabled else { return } + logBackground("theme action \(message)") + } + + private func actionLabel(for action: ghostty_action_s) -> String { + switch action.tag { + case GHOSTTY_ACTION_RELOAD_CONFIG: + return "reload_config" + case GHOSTTY_ACTION_CONFIG_CHANGE: + return "config_change" + case GHOSTTY_ACTION_COLOR_CHANGE: + return "color_change" + default: + return String(describing: action.tag) + } + } + + private func logAction(_ action: ghostty_action_s, target: ghostty_target_s, tabId: UUID?, surfaceId: UUID?) { + guard backgroundLogEnabled else { return } + let targetLabel = target.tag == GHOSTTY_TARGET_SURFACE ? "surface" : "app" + logBackground( + "action event target=\(targetLabel) action=\(actionLabel(for: action)) tab=\(tabId?.uuidString ?? "nil") surface=\(surfaceId?.uuidString ?? "nil")" ) } @@ -725,6 +865,12 @@ class GhosttyApp { private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || + action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || + action.tag == GHOSTTY_ACTION_COLOR_CHANGE { + logAction(action, target: target, tabId: nil, surfaceId: nil) + } + if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION { let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" @@ -752,8 +898,9 @@ class GhosttyApp { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { let soft = action.action.reload_config.soft + logThemeAction("reload request target=app soft=\(soft)") performOnMain { - GhosttyApp.shared.reloadConfiguration(soft: soft) + GhosttyApp.shared.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true } @@ -761,16 +908,18 @@ class GhosttyApp { if action.tag == GHOSTTY_ACTION_COLOR_CHANGE, action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change - defaultBackgroundColor = NSColor( + let resolvedColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) - if backgroundLogEnabled { - logBackground("OSC background change (app target) color=\(defaultBackgroundColor)") - } - notifyDefaultBackgroundDidChange() + applyDefaultBackground( + color: resolvedColor, + opacity: defaultBackgroundOpacity, + source: "action.color_change.app", + scope: .app + ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } @@ -778,7 +927,11 @@ class GhosttyApp { } if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE { - updateDefaultBackground(from: action.action.config_change.config) + updateDefaultBackground( + from: action.action.config_change.config, + source: "action.config_change.app", + scope: .app + ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } @@ -789,6 +942,16 @@ class GhosttyApp { } guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || + action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || + action.tag == GHOSTTY_ACTION_COLOR_CHANGE { + logAction( + action, + target: target, + tabId: surfaceView.tabId, + surfaceId: surfaceView.terminalSurface?.id + ) + } switch action.tag { case GHOSTTY_ACTION_NEW_SPLIT: @@ -998,19 +1161,26 @@ class GhosttyApp { } return true case GHOSTTY_ACTION_CONFIG_CHANGE: - updateDefaultBackground(from: action.action.config_change.config) + updateDefaultBackground( + from: action.action.config_change.config, + source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", + scope: .surface + ) DispatchQueue.main.async { surfaceView.applyWindowBackgroundIfActive() } return true case GHOSTTY_ACTION_RELOAD_CONFIG: let soft = action.action.reload_config.soft + logThemeAction( + "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" + ) return performOnMain { - if let surface = surfaceView.terminalSurface?.surface { - GhosttyApp.shared.reloadConfiguration(for: surface, soft: soft) - } else { - GhosttyApp.shared.reloadConfiguration(soft: soft) - } + // Keep all runtime theme/default-background state in the same path. + GhosttyApp.shared.reloadConfiguration( + soft: soft, + source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" + ) return true } case GHOSTTY_ACTION_KEY_SEQUENCE: @@ -1102,7 +1272,8 @@ class GhosttyApp { } func logBackground(_ message: String) { - let line = "cmux bg: \(message)\n" + let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date()) + let line = "\(timestamp) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) @@ -1963,6 +2134,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() + if GhosttyApp.shared.backgroundLogEnabled { + let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) + GhosttyApp.shared.logBackground( + "surface appearance changed tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil")" + ) + } applySurfaceColorScheme() } @@ -2105,10 +2282,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT if !force, appliedColorScheme == scheme { + if GhosttyApp.shared.backgroundLogEnabled { + let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" + GhosttyApp.shared.logBackground( + "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=false" + ) + } return } ghostty_surface_set_color_scheme(surface, scheme) appliedColorScheme = scheme + if GhosttyApp.shared.backgroundLogEnabled { + let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" + GhosttyApp.shared.logBackground( + "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=true" + ) + } } @discardableResult @@ -3028,6 +3217,8 @@ enum GhosttyNotificationKey { static let title = "ghostty.title" static let backgroundColor = "ghostty.backgroundColor" static let backgroundOpacity = "ghostty.backgroundOpacity" + static let backgroundEventId = "ghostty.backgroundEventId" + static let backgroundSource = "ghostty.backgroundSource" } extension Notification.Name { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 1ab7655f..3c33f1ab 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -104,11 +104,13 @@ struct WorkspaceContentView: View { } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value + let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" // Payload ordering can lag across rapid config/theme updates. // Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned // with Ghostty's current runtime theme. refreshGhosttyAppearanceConfig( - reason: "ghosttyDefaultBackgroundDidChange:payload=\(payloadHex)" + reason: "ghosttyDefaultBackgroundDidChange:event=\(eventId.map(String.init) ?? "nil"):source=\(source):payload=\(payloadHex)" ) } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7ed81980..091d274e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -211,7 +211,7 @@ struct cmuxApp: App { GhosttyApp.shared.openConfigurationInTextEdit() } Button("Reload Configuration") { - GhosttyApp.shared.reloadConfiguration() + GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") } .keyboardShortcut(",", modifiers: [.command, .shift]) Divider() diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 80a5d1f4..1971d481 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -162,6 +162,39 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() { + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .unscoped, + incomingScope: .app + ) + ) + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .app, + incomingScope: .surface + ) + ) + XCTAssertTrue( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .surface + ) + ) + XCTAssertFalse( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .app + ) + ) + XCTAssertFalse( + GhosttyApp.shouldApplyDefaultBackgroundUpdate( + currentScope: .surface, + incomingScope: .unscoped + ) + ) + } + func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { @@ -352,14 +385,17 @@ final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { expectation.expectedFulfillmentCount = 1 var postedUserInfos: [[AnyHashable: Any]] = [] - let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(delay: 0.01) { userInfo in - postedUserInfos.append(userInfo) - expectation.fulfill() - } + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher( + delay: 0.01, + postNotification: { userInfo in + postedUserInfos.append(userInfo) + expectation.fulfill() + } + ) DispatchQueue.main.async { - dispatcher.signal(backgroundColor: dark, opacity: 0.95) - dispatcher.signal(backgroundColor: light, opacity: 0.75) + dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark") + dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light") } wait(for: [expectation], timeout: 1.0) @@ -373,6 +409,14 @@ final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { 0.75, accuracy: 0.0001 ) + XCTAssertEqual( + (postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value, + 2 + ) + XCTAssertEqual( + postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String, + "test.light" + ) } func testSignalAcrossSeparateBurstsPostsMultipleNotifications() { @@ -386,16 +430,19 @@ final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase { expectation.expectedFulfillmentCount = 2 var postedHexes: [String] = [] - let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(delay: 0.01) { userInfo in - let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" - postedHexes.append(hex) - expectation.fulfill() - } + let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher( + delay: 0.01, + postNotification: { userInfo in + let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + postedHexes.append(hex) + expectation.fulfill() + } + ) DispatchQueue.main.async { - dispatcher.signal(backgroundColor: dark, opacity: 1.0) + dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark") DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - dispatcher.signal(backgroundColor: light, opacity: 1.0) + dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light") } } From 1b954f1d680ff3e007a4bad460af7b73f3c39522 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:11:27 -0800 Subject: [PATCH 023/136] Sync theme updates in same frame on theme switch --- Sources/ContentView.swift | 21 +++++++++++++++------ Sources/GhosttyTerminalView.swift | 4 +++- Sources/WorkspaceContentView.swift | 4 +++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 82de33b1..21f40272 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -843,7 +843,6 @@ struct ContentView: View { @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) - @State private var titlebarThemeUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? @State private var sidebarResizerPointerMonitor: Any? @State private var isResizerBandActive = false @@ -1261,10 +1260,15 @@ struct ContentView: View { } } - private func scheduleTitlebarThemeRefresh() { - titlebarThemeUpdateCoalescer.signal { + private func scheduleTitlebarThemeRefresh(reason: String) { + withTransaction(Transaction(animation: nil)) { titlebarThemeGeneration &+= 1 } + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "titlebar theme refresh reason=\(reason) generation=\(titlebarThemeGeneration)" + ) + } } private var focusedDirectory: String? { @@ -1406,11 +1410,16 @@ struct ContentView: View { }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyConfigDidReload"))) { _ in - scheduleTitlebarThemeRefresh() + scheduleTitlebarThemeRefresh(reason: "ghosttyConfigDidReload") }) - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { _ in - scheduleTitlebarThemeRefresh() + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { notification in + let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" + let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value + let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" + scheduleTitlebarThemeRefresh( + reason: "ghosttyDefaultBackgroundDidChange:event=\(eventId.map(String.init) ?? "nil"):source=\(source):payload=\(payloadHex)" + ) }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2b80a7f3..f0dd19e6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -283,7 +283,9 @@ class GhosttyApp { private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = - GhosttyDefaultBackgroundNotificationDispatcher(logEvent: { [weak self] message in + // Theme chrome should track terminal theme changes in the same frame. + // Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame. + GhosttyDefaultBackgroundNotificationDispatcher(delay: 0, logEvent: { [weak self] message in guard let self, self.backgroundLogEnabled else { return } self.logBackground(message) }) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 3c33f1ab..24743fe4 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -183,7 +183,9 @@ struct WorkspaceContentView: View { logTheme( "theme refresh workspace=\(workspace.id.uuidString) reason=\(reason) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" ) - config = next + withTransaction(Transaction(animation: nil)) { + config = next + } workspace.applyGhosttyChrome(from: next) } From a3f3e20d72c176f4cb54d1536d227794e2acd8a6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:31:22 -0800 Subject: [PATCH 024/136] Unify Cmd+Shift+H flash path across panel types --- Sources/GhosttyTerminalView.swift | 20 ++++++----- Sources/Panels/BrowserPanelView.swift | 31 +++++++++------- Sources/Panels/Panel.swift | 36 +++++++++++++++++++ Sources/Workspace.swift | 9 +---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 34 ++++++++++++++++++ 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2b80a7f3..f4a83671 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3884,15 +3884,17 @@ final class GhosttySurfaceScrollView: NSView { self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = [0, 1, 0, 1, 0] - animation.keyTimes = [0, 0.25, 0.5, 0.75, 1] - animation.duration = 0.9 - animation.timingFunctions = [ - CAMediaTimingFunction(name: .easeOut), - CAMediaTimingFunction(name: .easeIn), - CAMediaTimingFunction(name: .easeOut), - CAMediaTimingFunction(name: .easeIn) - ] + animation.values = FocusFlashPattern.values.map { NSNumber(value: $0) } + animation.keyTimes = FocusFlashPattern.keyTimes.map { NSNumber(value: $0) } + animation.duration = FocusFlashPattern.duration + animation.timingFunctions = FocusFlashPattern.curves.map { curve in + switch curve { + case .easeIn: + return CAMediaTimingFunction(name: .easeIn) + case .easeOut: + return CAMediaTimingFunction(name: .easeOut) + } + } self.flashLayer.add(animation, forKey: "cmux.flash") } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 027edb1b..d4f9b2ba 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -190,7 +190,7 @@ struct BrowserPanelView: View { @State private var omnibarHasMarkedText: Bool = false @State private var suppressNextFocusLostRevert: Bool = false @State private var focusFlashOpacity: Double = 0.0 - @State private var focusFlashFadeWorkItem: DispatchWorkItem? + @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var isBrowserThemeMenuPresented = false @@ -692,20 +692,27 @@ struct BrowserPanelView: View { } private func triggerFocusFlashAnimation() { - focusFlashFadeWorkItem?.cancel() - focusFlashFadeWorkItem = nil + focusFlashAnimationGeneration &+= 1 + let generation = focusFlashAnimationGeneration + focusFlashOpacity = FocusFlashPattern.values.first ?? 0 - withAnimation(.easeOut(duration: 0.08)) { - focusFlashOpacity = 1.0 - } - - let item = DispatchWorkItem { - withAnimation(.easeOut(duration: 0.35)) { - focusFlashOpacity = 0.0 + for segment in FocusFlashPattern.segments { + DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { + guard focusFlashAnimationGeneration == generation else { return } + withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { + focusFlashOpacity = segment.targetOpacity + } } } - focusFlashFadeWorkItem = item - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item) + } + + private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { + switch curve { + case .easeIn: + return .easeIn(duration: duration) + case .easeOut: + return .easeOut(duration: duration) + } } private func syncURLFromPanel() { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 427d53c8..4a9f62ff 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -7,6 +7,39 @@ public enum PanelType: String, Codable, Sendable { case browser } +enum FocusFlashCurve: Equatable { + case easeIn + case easeOut +} + +struct FocusFlashSegment: Equatable { + let delay: TimeInterval + let duration: TimeInterval + let targetOpacity: Double + let curve: FocusFlashCurve +} + +enum FocusFlashPattern { + static let values: [Double] = [0, 1, 0, 1, 0] + static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] + static let duration: TimeInterval = 0.9 + static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] + + static var segments: [FocusFlashSegment] { + let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) + return (0.. NSEvent { From e4379a136c7287984029d058d55d40cecd4a1eec Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:37:48 -0800 Subject: [PATCH 025/136] Match terminal flash ring padding to browser --- Sources/GhosttyTerminalView.swift | 23 +++++++++++++++---- Sources/Panels/BrowserPanelView.swift | 4 ++-- Sources/Panels/Panel.swift | 2 ++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 2 ++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f4a83671..71258f18 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4429,16 +4429,29 @@ final class GhosttySurfaceScrollView: NSView { } private func updateNotificationRingPath() { - updateOverlayRingPath(layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds) + updateOverlayRingPath( + layer: notificationRingLayer, + bounds: notificationRingOverlayView.bounds, + inset: 2, + radius: 6 + ) } private func updateFlashPath() { - updateOverlayRingPath(layer: flashLayer, bounds: flashOverlayView.bounds) + updateOverlayRingPath( + layer: flashLayer, + bounds: flashOverlayView.bounds, + inset: CGFloat(FocusFlashPattern.ringInset), + radius: CGFloat(FocusFlashPattern.ringCornerRadius) + ) } - private func updateOverlayRingPath(layer: CAShapeLayer, bounds: CGRect) { - let inset: CGFloat = 2 - let radius: CGFloat = 6 + private func updateOverlayRingPath( + layer: CAShapeLayer, + bounds: CGRect, + inset: CGFloat, + radius: CGFloat + ) { layer.frame = bounds guard bounds.width > inset * 2, bounds.height > inset * 2 else { layer.path = nil diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index d4f9b2ba..f0c65a81 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -255,10 +255,10 @@ struct BrowserPanelView: View { webView } .overlay { - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) .stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3) .shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10) - .padding(6) + .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } .overlay(alignment: .topLeading) { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 4a9f62ff..a0a719c4 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -24,6 +24,8 @@ enum FocusFlashPattern { static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] static let duration: TimeInterval = 0.9 static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] + static let ringInset: Double = 6 + static let ringCornerRadius: Double = 10 static var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 689b736a..45e2a54e 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -213,6 +213,8 @@ final class FocusFlashPatternTests: XCTestCase { XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) + XCTAssertEqual(FocusFlashPattern.ringInset, 6, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) } func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() { From eb1f0bdd43c93f6061fb81c80306290eec13788b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:44:28 -0800 Subject: [PATCH 026/136] Unify theme refresh path across workspace and titlebar --- Sources/ContentView.swift | 65 ++++++++++++++++++++++-------- Sources/GhosttyTerminalView.swift | 41 +++++++++++++++++-- Sources/Workspace.swift | 13 ++++-- Sources/WorkspaceContentView.swift | 62 ++++++++++++++++++++++++++-- 4 files changed, 153 insertions(+), 28 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 21f40272..7a8cb326 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1127,7 +1127,16 @@ struct ContentView: View { workspace: tab, isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, - workspacePortalPriority: portalPriority + workspacePortalPriority: portalPriority, + onThemeRefreshRequest: { reason, eventId, source, payloadHex in + scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: tab.id, + reason: reason, + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) + } ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -1260,17 +1269,47 @@ struct ContentView: View { } } - private func scheduleTitlebarThemeRefresh(reason: String) { - withTransaction(Transaction(animation: nil)) { - titlebarThemeGeneration &+= 1 - } + private func scheduleTitlebarThemeRefresh( + reason: String, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { + let previousGeneration = titlebarThemeGeneration + titlebarThemeGeneration &+= 1 if GhosttyApp.shared.backgroundLogEnabled { + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" GhosttyApp.shared.logBackground( - "titlebar theme refresh reason=\(reason) generation=\(titlebarThemeGeneration)" + "titlebar theme refresh scheduled reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousGeneration=\(previousGeneration) generation=\(titlebarThemeGeneration) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" ) } } + private func scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: UUID, + reason: String, + backgroundEventId: UInt64?, + backgroundSource: String?, + notificationPayloadHex: String? + ) { + guard tabManager.selectedTabId == workspaceId else { + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh skipped workspace=\(workspaceId.uuidString) selected=\(tabManager.selectedTabId?.uuidString ?? "nil") reason=\(reason)" + ) + return + } + + scheduleTitlebarThemeRefresh( + reason: reason, + backgroundEventId: backgroundEventId, + backgroundSource: backgroundSource, + notificationPayloadHex: notificationPayloadHex + ) + } + private var focusedDirectory: String? { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -1409,16 +1448,10 @@ struct ContentView: View { scheduleTitlebarTextRefresh() }) - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyConfigDidReload"))) { _ in - scheduleTitlebarThemeRefresh(reason: "ghosttyConfigDidReload") - }) - - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { notification in - let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" - let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value - let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" - scheduleTitlebarThemeRefresh( - reason: "ghosttyDefaultBackgroundDidChange:event=\(eventId.map(String.init) ?? "nil"):source=\(source):payload=\(payloadHex)" + view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" ) }) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f0dd19e6..8636e1ae 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -183,7 +183,9 @@ final class GhosttyDefaultBackgroundNotificationDispatcher { let source = pendingSource pendingUserInfo = nil logEvent?("bg notify flushed id=\(eventId) source=\(source)") + logEvent?("bg notify posting id=\(eventId) source=\(source)") postNotification(userInfo) + logEvent?("bg notify posted id=\(eventId) source=\(source)") } } @@ -278,6 +280,9 @@ class GhosttyApp { return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() + private let backgroundLogStartUptime = ProcessInfo.processInfo.systemUptime + private let backgroundLogLock = NSLock() + private var backgroundLogSequence: UInt64 = 0 private var appObservers: [NSObjectProtocol] = [] private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped @@ -1168,8 +1173,10 @@ class GhosttyApp { source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", scope: .surface ) - DispatchQueue.main.async { - surfaceView.applyWindowBackgroundIfActive() + if backgroundLogEnabled { + logBackground( + "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" + ) } return true case GHOSTTY_ACTION_RELOAD_CONFIG: @@ -1275,7 +1282,16 @@ class GhosttyApp { func logBackground(_ message: String) { let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date()) - let line = "\(timestamp) cmux bg: \(message)\n" + let uptimeMs = (ProcessInfo.processInfo.systemUptime - backgroundLogStartUptime) * 1000 + let frame60 = Int((CACurrentMediaTime() * 60.0).rounded(.down)) + let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down)) + let threadLabel = Thread.isMainThread ? "main" : "background" + backgroundLogLock.lock() + defer { backgroundLogLock.unlock() } + backgroundLogSequence &+= 1 + let sequence = backgroundLogSequence + let line = + "\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) @@ -1967,6 +1983,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? private var appliedColorScheme: ghostty_color_scheme_e? + private var lastLoggedSurfaceBackgroundSignature: String? + private var lastLoggedWindowBackgroundSignature: String? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] #if DEBUG @@ -2027,6 +2045,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { CATransaction.commit() } terminalSurface?.hostedView.setBackgroundColor(color) + if GhosttyApp.shared.backgroundLogEnabled { + let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + if signature != lastLoggedSurfaceBackgroundSignature { + lastLoggedSurfaceBackgroundSignature = signature + GhosttyApp.shared.logBackground( + "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + ) + } + } } func applyWindowBackgroundIfActive() { @@ -2044,7 +2071,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { window.isOpaque = color.alphaComponent >= 1.0 } if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + let signature = "\(cmuxShouldUseTransparentBackgroundWindow() ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + if signature != lastLoggedWindowBackgroundSignature { + lastLoggedWindowBackgroundSignature = signature + GhosttyApp.shared.logBackground( + "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") transparent=\(cmuxShouldUseTransparentBackgroundWindow()) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + ) + } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 99d17dcf..cded0114 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -347,11 +347,11 @@ final class Workspace: Identifiable, ObservableObject { ) } - func applyGhosttyChrome(from config: GhosttyConfig) { - applyGhosttyChrome(backgroundColor: config.backgroundColor) + func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { + applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason) } - func applyGhosttyChrome(backgroundColor: NSColor) { + func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { let currentChromeColors = bonsplitController.configuration.appearance.chromeColors let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && @@ -361,7 +361,7 @@ final class Workspace: Identifiable, ObservableObject { let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" ) } @@ -369,6 +369,11 @@ final class Workspace: Identifiable, ObservableObject { return } bonsplitController.configuration.appearance.chromeColors = nextChromeColors + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + ) + } } init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 24743fe4..d209b4d2 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -9,6 +9,12 @@ struct WorkspaceContentView: View { let isWorkspaceVisible: Bool let isWorkspaceInputActive: Bool let workspacePortalPriority: Int + let onThemeRefreshRequest: (( + _ reason: String, + _ backgroundEventId: UInt64?, + _ backgroundSource: String?, + _ notificationPayloadHex: String? + ) -> Void)? @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -106,11 +112,17 @@ struct WorkspaceContentView: View { let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil" let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil" + logTheme( + "theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) // Payload ordering can lag across rapid config/theme updates. // Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned // with Ghostty's current runtime theme. refreshGhosttyAppearanceConfig( - reason: "ghosttyDefaultBackgroundDidChange:event=\(eventId.map(String.init) ?? "nil"):source=\(source):payload=\(payloadHex)" + reason: "ghosttyDefaultBackgroundDidChange", + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex ) } } @@ -174,19 +186,61 @@ struct WorkspaceContentView: View { return next } - private func refreshGhosttyAppearanceConfig(reason: String, backgroundOverride: NSColor? = nil) { + private func refreshGhosttyAppearanceConfig( + reason: String, + backgroundOverride: NSColor? = nil, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { let previousBackgroundHex = config.backgroundColor.hexString() let next = Self.resolveGhosttyAppearanceConfig( reason: reason, backgroundOverride: backgroundOverride ) + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" + let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString() + let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear" logTheme( - "theme refresh workspace=\(workspace.id.uuidString) reason=\(reason) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" + "theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" ) withTransaction(Transaction(animation: nil)) { config = next + if shouldRequestTitlebarRefresh { + onThemeRefreshRequest?( + reason, + backgroundEventId, + backgroundSource, + notificationPayloadHex + ) + } } - workspace.applyGhosttyChrome(from: next) + if !shouldRequestTitlebarRefresh { + logTheme( + "theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())" + ) + } + logTheme( + "theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())" + ) + let chromeReason = + "refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)" + workspace.applyGhosttyChrome(from: next, reason: chromeReason) + if let terminalPanel = workspace.focusedTerminalPanel { + terminalPanel.applyWindowBackgroundIfActive() + logTheme( + "theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")" + ) + } else { + logTheme( + "theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")" + ) + } + logTheme( + "theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" + ) } private func logTheme(_ message: String) { From cb0efa3eddf55afcdec20e39aaaaa9595d37026a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:59:59 -0800 Subject: [PATCH 027/136] Fix early split child-exit close race --- Sources/GhosttyTerminalView.swift | 29 +++++++----- Sources/TabManager.swift | 48 ++++++++++++++------ cmuxUITests/CloseWorkspaceCmdDUITests.swift | 49 +++++++++++++++++++++ 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1a936ec0..a040b3be 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1126,11 +1126,13 @@ class GhosttyApp { // "Process exited. Press any key..." into the terminal unless the host // handles this action. For cmux, the correct behavior is to close // the panel immediately (no prompt). + let callbackTabId = surfaceView.tabId + let callbackSurfaceId = surfaceView.terminalSurface?.id #if DEBUG cmuxWriteChildExitProbe( [ - "probeShowChildExitedTabId": surfaceView.tabId?.uuidString ?? "", - "probeShowChildExitedSurfaceId": surfaceView.terminalSurface?.id.uuidString ?? "", + "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", + "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", ], increments: ["probeShowChildExitedCount": 1] ) @@ -1139,12 +1141,12 @@ class GhosttyApp { // dispatching this action callback. DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } - if let tabId = surfaceView.tabId, - let surfaceId = surfaceView.terminalSurface?.id, - let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, - let workspace = manager.tabs.first(where: { $0.id == tabId }), - workspace.panels[surfaceId] != nil { - manager.closePanelAfterChildExited(tabId: tabId, surfaceId: surfaceId) + if let callbackTabId, + let callbackSurfaceId, + let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), + workspace.panels[callbackSurfaceId] != nil { + manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) } } // Always report handled so Ghostty doesn't print the fallback prompt. @@ -1945,7 +1947,10 @@ final class TerminalSurface: Identifiable, ObservableObject { } deinit { - if let surface = surface { + guard let surface else { return } + + // Defer teardown to the next main-actor turn so close callbacks can unwind first. + Task.detached { @MainActor in ghostty_surface_free(surface) } } @@ -4066,7 +4071,7 @@ final class GhosttySurfaceScrollView: NSView { /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike `sendText`, which bypasses key translation. @discardableResult - func sendSyntheticCtrlDForUITest() -> Bool { + func sendSyntheticCtrlDForUITest(modifierFlags: NSEvent.ModifierFlags = [.control]) -> Bool { guard let window else { return false } window.makeFirstResponder(surfaceView) @@ -4074,7 +4079,7 @@ final class GhosttySurfaceScrollView: NSView { guard let keyDown = NSEvent.keyEvent( with: .keyDown, location: .zero, - modifierFlags: [.control], + modifierFlags: modifierFlags, timestamp: timestamp, windowNumber: window.windowNumber, context: nil, @@ -4087,7 +4092,7 @@ final class GhosttySurfaceScrollView: NSView { guard let keyUp = NSEvent.keyEvent( with: .keyUp, location: .zero, - modifierFlags: [.control], + modifierFlags: modifierFlags, timestamp: timestamp + 0.001, windowNumber: window.windowNumber, context: nil, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ba6c3261..ce3e2b3b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2732,6 +2732,8 @@ class TabManager: ObservableObject { let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) + let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) let expectedPanelsAfter = max( @@ -2870,7 +2872,9 @@ class TabManager: ObservableObject { } tab.focusPanel(exitPanelId) - try? await Task.sleep(nanoseconds: 100_000_000) + if !useEarlyCtrlShiftTrigger { + try? await Task.sleep(nanoseconds: 100_000_000) + } let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in @@ -2974,21 +2978,31 @@ class TabManager: ObservableObject { return } - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(2.0) + let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift + ? [.control, .shift] + : [.control] + let shouldWaitForSurface = !useEarlyCtrlShiftTrigger + var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false - while Date() < readyDeadline { - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return + if shouldWaitForSurface { + // Wait for the target panel to be fully attached after split churn. + let readyDeadline = Date().addingTimeInterval(2.0) + while Date() < readyDeadline { + guard let panel = tab.terminalPanel(for: exitPanelId) else { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return + } + attachedBeforeTrigger = panel.hostedView.window != nil + hasSurfaceBeforeTrigger = panel.surface.surface != nil + if attachedBeforeTrigger, hasSurfaceBeforeTrigger { + break + } + try? await Task.sleep(nanoseconds: 50_000_000) } + } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) } write([ "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", @@ -3000,7 +3014,7 @@ class TabManager: ObservableObject { return } // Exercise the real key path (ghostty_surface_key for Ctrl+D). - if panel.hostedView.sendSyntheticCtrlDForUITest() { + if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey1": "1"]) } else { write([ @@ -3012,13 +3026,19 @@ class TabManager: ObservableObject { // In strict mode, never mask routing bugs with fallback writes. if strictKeyOnly { - write(["autoTriggerMode": "strict_ctrl_d"]) + let strictModeLabel: String = { + if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if triggerUsesShift { return "strict_ctrl_shift_d" } + return "strict_ctrl_d" + }() + write(["autoTriggerMode": strictModeLabel]) return } // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. try? await Task.sleep(nanoseconds: 450_000_000) - if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() { + if tab.panels[exitPanelId] != nil, + panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey2": "1"]) } } diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 02ec9239..d8054225 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -546,6 +546,55 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } } + func testCtrlShiftDEarlyDuringSplitStartupKeepsWindowOpen() { + let attempts = 12 + for attempt in 1...attempts { + let app = XCUIApplication() + let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-shift-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_shift_d" + app.launch() + app.activate() + defer { app.terminate() } + + XCTAssertTrue( + waitForAnyJSON(atPath: dataPath, timeout: 12.0), + "Attempt \(attempt): expected early Ctrl+Shift+D setup data at \(dataPath)" + ) + guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { + XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+Shift+D. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = done["setupError"], !setupError.isEmpty { + XCTFail("Attempt \(attempt): setup failed: \(setupError)") + return + } + + let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1 + let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1 + let closedWorkspace = (done["closedWorkspace"] ?? "") == "1" + let timedOut = (done["timedOut"] ?? "") == "1" + let triggerMode = done["autoTriggerMode"] ?? "" + + XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+Shift+D timed out. data=\(done)") + XCTAssertEqual(triggerMode, "strict_early_ctrl_shift_d", "Attempt \(attempt): expected strict early Ctrl+Shift+D trigger mode. data=\(done)") + XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+Shift+D. data=\(done)") + XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+Shift+D. data=\(done)") + XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+Shift+D. data=\(done)") + XCTAssertTrue( + waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), + "Attempt \(attempt): app window should remain open after early Ctrl+Shift+D. data=\(done)" + ) + } + } + private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { From 2499ba1bb2fbabfaff40522de8d12e1a985a06ae Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:09:19 -0800 Subject: [PATCH 028/136] Fix browser-surface click focus without regressing open (#355) * Allow click-to-focus for unfocused browser surfaces * Add browser click-focus diagnostics and guard fix * Allow pointer-initiated browser focus through responder guard --- Sources/AppDelegate.swift | 110 +++++++++++++++- Sources/Panels/BrowserPanel.swift | 10 ++ Sources/Panels/BrowserPanelView.swift | 38 +++++- Sources/Panels/CmuxWebView.swift | 64 ++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 123 ++++++++++++++++++ 5 files changed, 335 insertions(+), 10 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7bafbc8e..f9003b88 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1548,6 +1548,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = didInstallWindowFirstResponderSwizzle } +#if DEBUG + static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { + cmuxFirstResponderGuardCurrentEventOverride = currentEvent + cmuxFirstResponderGuardHitViewOverride = hitView + } + + static func clearWindowFirstResponderGuardTesting() { + cmuxFirstResponderGuardCurrentEventOverride = nil + cmuxFirstResponderGuardHitViewOverride = nil + } +#endif + private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle @@ -2887,6 +2899,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { [weak self] notification in guard let self else { return } guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() if self.browserAddressBarFocusedPanelId == panelId { self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() @@ -3853,19 +3866,59 @@ enum MenuBarIconRenderer { } +#if DEBUG +private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? +private var cmuxFirstResponderGuardHitViewOverride: NSView? +#endif + private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { if let responder, let webView = Self.cmuxOwningWebView(for: responder), - !webView.allowsFirstResponderAcquisition { -#if DEBUG - dlog( - "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self))" + !webView.allowsFirstResponderAcquisitionEffective { + let currentEvent = Self.cmuxCurrentEvent(for: self) + let pointerInitiatedFocus = Self.cmuxShouldAllowPointerInitiatedWebViewFocus( + window: self, + webView: webView, + event: currentEvent ) + if pointerInitiatedFocus { +#if DEBUG + dlog( + "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) #endif - return false + } else { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + return false + } } +#if DEBUG + if let responder, + let webView = Self.cmuxOwningWebView(for: responder) { + dlog( + "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + ) + } +#endif return cmux_makeFirstResponder(responder) } @@ -4024,4 +4077,49 @@ private extension NSWindow { return nil } + + private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? { +#if DEBUG + if let override = cmuxFirstResponderGuardCurrentEventOverride { + return override + } +#endif + return NSApp.currentEvent + } + + private static func cmuxHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { +#if DEBUG + if let override = cmuxFirstResponderGuardHitViewOverride { + return override + } +#endif + return window.contentView?.hitTest(event.locationInWindow) + } + + private static func cmuxShouldAllowPointerInitiatedWebViewFocus( + window: NSWindow, + webView: CmuxWebView, + event: NSEvent? + ) -> Bool { + guard let event else { return false } + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + break + default: + return false + } + + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return false + } + if let eventWindow = event.window, eventWindow !== window { + return false + } + + guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event), + let hitWebView = cmuxOwningWebView(for: hitView) else { + return false + } + return hitWebView === webView + } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2a683ba5..6255672e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2117,10 +2117,20 @@ extension BrowserPanel { } func beginSuppressWebViewFocusForAddressBar() { + if !suppressWebViewFocusForAddressBar { +#if DEBUG + dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))") +#endif + } suppressWebViewFocusForAddressBar = true } func endSuppressWebViewFocusForAddressBar() { + if suppressWebViewFocusForAddressBar { +#if DEBUG + dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))") +#endif + } suppressWebViewFocusForAddressBar = false } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f0c65a81..0294766f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -291,6 +291,13 @@ struct BrowserPanelView: View { guard let webView = note.object as? CmuxWebView else { return false } return webView === panel?.webView }) { _ in +#if DEBUG + dlog( + "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " + + "isFocused=\(isFocused ? 1 : 0) " + + "addressFocused=\(addressBarFocused ? 1 : 0)" + ) +#endif onRequestPanelFocus() } .onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in @@ -317,6 +324,7 @@ struct BrowserPanelView: View { syncURLFromPanel() // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() + syncWebViewResponderPolicyWithViewState(reason: "onAppear") BrowserHistoryStore.shared.loadIfNeeded() } .onChange(of: panel.focusFlashToken) { _ in @@ -356,6 +364,7 @@ struct BrowserPanelView: View { hideSuggestions() addressBarFocused = false } + syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged") } .onChange(of: addressBarFocused) { focused in let urlString = panel.preferredURLStringForOmnibar() ?? "" @@ -383,6 +392,7 @@ struct BrowserPanelView: View { } inlineCompletion = nil } + syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged") } .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in guard let panelId = notification.object as? UUID, panelId == panel.id else { return } @@ -715,6 +725,21 @@ struct BrowserPanelView: View { } } + private func syncWebViewResponderPolicyWithViewState(reason: String) { + guard let cmuxWebView = panel.webView as? CmuxWebView else { return } + let next = isFocused && !panel.shouldSuppressWebViewFocus() + if cmuxWebView.allowsFirstResponderAcquisition != next { +#if DEBUG + dlog( + "browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(next ? 1 : 0) reason=\(reason)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = next + } + private func syncURLFromPanel() { let urlString = panel.preferredURLStringForOmnibar() ?? "" let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString)) @@ -3379,7 +3404,18 @@ struct WebViewRepresentable: NSViewRepresentable { isPanelFocused: Bool ) { guard let cmuxWebView = webView as? CmuxWebView else { return } - cmuxWebView.allowsFirstResponderAcquisition = isPanelFocused && !panel.shouldSuppressWebViewFocus() + let next = isPanelFocused && !panel.shouldSuppressWebViewFocus() + if cmuxWebView.allowsFirstResponderAcquisition != next { +#if DEBUG + dlog( + "browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " + + "web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " + + "new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " + + "suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)" + ) +#endif + } + cmuxWebView.allowsFirstResponderAcquisition = next } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 93f5a321..83941484 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import ObjectiveC import WebKit @@ -25,10 +26,56 @@ final class CmuxWebView: WKWebView { /// Guard against background panes stealing first responder (e.g. page autofocus). /// BrowserPanelView updates this as pane focus state changes. var allowsFirstResponderAcquisition: Bool = true + private var pointerFocusAllowanceDepth: Int = 0 + var allowsFirstResponderAcquisitionEffective: Bool { + allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 + } + var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } override func becomeFirstResponder() -> Bool { - guard allowsFirstResponderAcquisition else { return false } - return super.becomeFirstResponder() + guard allowsFirstResponderAcquisitionEffective else { +#if DEBUG + let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + dlog( + "browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)" + ) +#endif + return false + } + let result = super.becomeFirstResponder() +#if DEBUG + let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + dlog( + "browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)" + ) +#endif + return result + } + + /// Temporarily permits focus acquisition for explicit pointer-driven interactions + /// (mouse click into this webview) while keeping background autofocus blocked. + func withPointerFocusAllowance(_ body: () -> Void) { + pointerFocusAllowanceDepth += 1 +#if DEBUG + dlog( + "browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " + + "depth=\(pointerFocusAllowanceDepth)" + ) +#endif + defer { + pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1) +#if DEBUG + dlog( + "browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " + + "depth=\(pointerFocusAllowanceDepth)" + ) +#endif + } + body() } override func performKeyEquivalent(with event: NSEvent) -> Bool { @@ -71,8 +118,19 @@ final class CmuxWebView: WKWebView { // NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so // bonsplit focus tracks which pane the user clicked in. override func mouseDown(with event: NSEvent) { +#if DEBUG + let windowNumber = window?.windowNumber ?? -1 + let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "browser.focus.mouseDown web=\(ObjectIdentifier(self)) " + + "policy=\(allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)" + ) +#endif NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self) - super.mouseDown(with: event) + withPointerFocusAllowance { + super.mouseDown(with: event) + } } // MARK: - Mouse back/forward buttons & middle-click diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 45e2a54e..05af20eb 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -136,6 +136,38 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + @MainActor + func testPointerFocusAllowanceCanTemporarilyOverrideBlockedFirstResponderAcquisition() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected focus to stay blocked by policy") + + webView.withPointerFocusAllowance { + XCTAssertTrue(webView.becomeFirstResponder(), "Expected explicit pointer intent to bypass policy") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(webView.becomeFirstResponder(), "Expected pointer allowance to be temporary") + } + @MainActor func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { _ = NSApplication.shared @@ -172,6 +204,97 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + @MainActor + func testWindowFirstResponderGuardAllowsDescendantDuringPointerFocusAllowance() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus outside pointer allowance") + + _ = window.makeFirstResponder(nil) + webView.withPointerFocusAllowance { + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer allowance to bypass guard") + } + + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer allowance to remain temporary") + } + + @MainActor + func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusWhenPolicyIsBlocked() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { + AppDelegate.clearWindowFirstResponderGuardTesting() + window.orderOut(nil) + } + + webView.allowsFirstResponderAcquisition = false + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context") + + let timestamp = ProcessInfo.processInfo.systemUptime + let pointerDownEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 5, y: 5), + modifierFlags: [], + timestamp: timestamp, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1.0 + ) + XCTAssertNotNil(pointerDownEvent) + + AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: descendant) + _ = window.makeFirstResponder(nil) + XCTAssertTrue(window.makeFirstResponder(descendant), "Expected pointer click context to bypass blocked policy") + + AppDelegate.clearWindowFirstResponderGuardTesting() + _ = window.makeFirstResponder(nil) + XCTAssertFalse(window.makeFirstResponder(descendant), "Expected pointer bypass to be limited to click context") + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() From aeabcdd58352c3ade82d69e914ad568dbca72578 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:13:33 -0800 Subject: [PATCH 029/136] Fix titlebar folder icon drag hit-testing --- Sources/WindowDragHandleView.swift | 25 ++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 55 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index e534e1bc..da9127e4 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,6 +1,26 @@ import AppKit import SwiftUI +/// Returns whether the titlebar drag handle should capture a hit at `point`. +/// We only claim the hit when no sibling view already handles it, so interactive +/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. +func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool { + guard dragHandleView.bounds.contains(point) else { return false } + guard let superview = dragHandleView.superview else { return true } + + for sibling in superview.subviews.reversed() { + guard sibling !== dragHandleView else { continue } + guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } + + let pointInSibling = dragHandleView.convert(point, to: sibling) + if sibling.hitTest(pointInSibling) != nil { + return false + } + } + + return true +} + /// A transparent view that enables dragging the window when clicking in empty titlebar space. /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// (e.g. sidebar tab reordering) don't move the whole window. @@ -15,7 +35,8 @@ struct WindowDragHandleView: NSViewRepresentable { private final class DraggableView: NSView { override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } + override func hitTest(_ point: NSPoint) -> NSView? { + windowDragHandleShouldCaptureHit(point, in: self) ? self : nil + } } } - diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 45e2a54e..9a2fa303 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3816,6 +3816,61 @@ final class WindowBrowserHostViewTests: XCTestCase { } } +@MainActor +final class WindowDragHandleHitTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle), + "Empty titlebar space should drag the window" + ) + } + + func testDragHandleYieldsWhenSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + container.addSubview(folderIconHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle), + "Interactive titlebar controls should receive the mouse event" + ) + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle)) + } + + func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + hidden.isHidden = true + container.addSubview(hidden) + + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle)) + } + + func testDragHandleDoesNotCaptureOutsideBounds() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle)) + } +} + @MainActor final class GhosttySurfaceOverlayTests: XCTestCase { func testInactiveOverlayVisibilityTracksRequestedState() { From 9ed3744485f72cebae6a594741431ff7530b3e6a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:15:31 -0800 Subject: [PATCH 030/136] Align startup split regression with Ctrl+D --- Sources/TabManager.swift | 7 +++++-- cmuxUITests/CloseWorkspaceCmdDUITests.swift | 22 ++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ce3e2b3b..d01ae0e7 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2733,6 +2733,8 @@ class TabManager: ObservableObject { let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" + let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2872,7 +2874,7 @@ class TabManager: ObservableObject { } tab.focusPanel(exitPanelId) - if !useEarlyCtrlShiftTrigger { + if !useEarlyTrigger { try? await Task.sleep(nanoseconds: 100_000_000) } @@ -2981,7 +2983,7 @@ class TabManager: ObservableObject { let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift ? [.control, .shift] : [.control] - let shouldWaitForSurface = !useEarlyCtrlShiftTrigger + let shouldWaitForSurface = !useEarlyTrigger var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false @@ -3028,6 +3030,7 @@ class TabManager: ObservableObject { if strictKeyOnly { let strictModeLabel: String = { if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } if triggerUsesShift { return "strict_ctrl_shift_d" } return "strict_ctrl_d" }() diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index d8054225..6955591a 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -546,11 +546,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } } - func testCtrlShiftDEarlyDuringSplitStartupKeepsWindowOpen() { + func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() { let attempts = 12 for attempt in 1...attempts { let app = XCUIApplication() - let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-shift-\(UUID().uuidString).json" + let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1" app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath @@ -558,17 +558,17 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1" app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1" app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1" - app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_shift_d" + app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d" app.launch() app.activate() defer { app.terminate() } XCTAssertTrue( waitForAnyJSON(atPath: dataPath, timeout: 12.0), - "Attempt \(attempt): expected early Ctrl+Shift+D setup data at \(dataPath)" + "Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)" ) guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else { - XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+Shift+D. data=\(loadJSON(atPath: dataPath) ?? [:])") + XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])") return } @@ -583,14 +583,14 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { let timedOut = (done["timedOut"] ?? "") == "1" let triggerMode = done["autoTriggerMode"] ?? "" - XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+Shift+D timed out. data=\(done)") - XCTAssertEqual(triggerMode, "strict_early_ctrl_shift_d", "Attempt \(attempt): expected strict early Ctrl+Shift+D trigger mode. data=\(done)") - XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+Shift+D. data=\(done)") - XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+Shift+D. data=\(done)") - XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+Shift+D. data=\(done)") + XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)") + XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)") + XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)") + XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)") + XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)") XCTAssertTrue( waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), - "Attempt \(attempt): app window should remain open after early Ctrl+Shift+D. data=\(done)" + "Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)" ) } } From 0c970858eea8cf180ad2929d26c6cdcfef498e7a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:23:06 -0800 Subject: [PATCH 031/136] Fix surface userdata lifetime during async free --- Sources/GhosttyTerminalView.swift | 51 +++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a040b3be..c3c369f7 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -227,6 +227,14 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? return .external(fallback) } +private final class GhosttySurfaceCallbackContext { + weak var surfaceView: GhosttyNSView? + + init(surfaceView: GhosttyNSView) { + self.surfaceView = surfaceView + } +} + // Minimal Ghostty wrapper for terminal rendering // This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation @@ -425,8 +433,7 @@ class GhosttyApp { } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard - guard let userdata else { return } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } guard let surface = surfaceView.terminalSurface?.surface else { return } let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) @@ -437,8 +444,8 @@ class GhosttyApp { } } runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in - guard let userdata, let content else { return } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let content else { return } + guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } guard let surface = surfaceView.terminalSurface?.surface else { return } ghostty_surface_complete_clipboard_request(surface, content, state, true) @@ -471,8 +478,7 @@ class GhosttyApp { } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in - guard let userdata else { return } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } let callbackSurfaceId = surfaceView.terminalSurface?.id let callbackTabId = surfaceView.tabId @@ -870,6 +876,12 @@ class GhosttyApp { } } + private static func surfaceView(from userdata: UnsafeMutableRawPointer?) -> GhosttyNSView? { + guard let userdata else { return nil } + let context = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + return context.surfaceView + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || @@ -947,8 +959,8 @@ class GhosttyApp { return false } - guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false } - let surfaceView = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let surfaceView = Self.surfaceView(from: ghostty_surface_userdata(target.target.surface)) + guard let surfaceView else { return false } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { @@ -1381,6 +1393,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 private var backgroundSurfaceStartQueued = false + private var surfaceCallbackContext: Unmanaged? @Published var searchState: SearchState? = nil { didSet { if let searchState { @@ -1578,7 +1591,10 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) - surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque() + let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view)) + surfaceConfig.userdata = callbackContext.toOpaque() + surfaceCallbackContext?.release() + surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext var envVars: [ghostty_env_var_s] = [] @@ -1707,6 +1723,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } if surface == nil { + surfaceCallbackContext?.release() + surfaceCallbackContext = nil print("Failed to create ghostty surface") #if DEBUG Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty_surface_new returned nil") @@ -1947,11 +1965,20 @@ final class TerminalSurface: Identifiable, ObservableObject { } deinit { - guard let surface else { return } + let callbackContext = surfaceCallbackContext + surfaceCallbackContext = nil - // Defer teardown to the next main-actor turn so close callbacks can unwind first. - Task.detached { @MainActor in + guard let surface else { + callbackContext?.release() + return + } + + // Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain + // callback userdata until surface free completes so callbacks never dereference + // a deallocated view pointer. + Task { @MainActor in ghostty_surface_free(surface) + callbackContext?.release() } } } From 5d63c5f035ad23d051c1c81998602ce019071d3c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:26:36 -0800 Subject: [PATCH 032/136] Add command palette (Cmd+Shift+P) (#358) Implements a VS Code-style command palette with fuzzy search, workspace/surface switching, rename mode, and keyboard navigation. Closes https://github.com/manaflow-ai/cmux/issues/133 --- Sources/AppDelegate.swift | 302 +- Sources/ContentView.swift | 3092 ++++++++++++++++- Sources/TabManager.swift | 15 +- Sources/TerminalController.swift | 322 +- Sources/cmuxApp.swift | 46 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 261 ++ cmuxTests/WorkspaceManualUnreadTests.swift | 331 ++ tests_v2/cmux.py | 21 + .../test_command_palette_backspace_go_back.py | 158 + tests_v2/test_command_palette_focus.py | 97 + ...mand_palette_focus_lock_workspace_spawn.py | 125 + .../test_command_palette_fuzzy_ranking.py | 133 + tests_v2/test_command_palette_modes.py | 194 ++ .../test_command_palette_navigation_keys.py | 143 + tests_v2/test_command_palette_rename_enter.py | 106 + .../test_command_palette_rename_select_all.py | 185 + ...test_command_palette_search_action_sync.py | 122 + ...command_palette_search_typing_stability.py | 121 + ..._switcher_cross_workspace_surface_focus.py | 172 + ...ommand_palette_switcher_renamed_surface.py | 160 + ...and_palette_switcher_surface_precedence.py | 155 + ...st_command_palette_switcher_type_labels.py | 127 + tests_v2/test_command_palette_window_scope.py | 99 + tests_v2/test_shortcut_window_scope.py | 107 + 24 files changed, 6581 insertions(+), 13 deletions(-) create mode 100644 tests_v2/test_command_palette_backspace_go_back.py create mode 100644 tests_v2/test_command_palette_focus.py create mode 100644 tests_v2/test_command_palette_focus_lock_workspace_spawn.py create mode 100644 tests_v2/test_command_palette_fuzzy_ranking.py create mode 100644 tests_v2/test_command_palette_modes.py create mode 100644 tests_v2/test_command_palette_navigation_keys.py create mode 100644 tests_v2/test_command_palette_rename_enter.py create mode 100644 tests_v2/test_command_palette_rename_select_all.py create mode 100644 tests_v2/test_command_palette_search_action_sync.py create mode 100644 tests_v2/test_command_palette_search_typing_stability.py create mode 100644 tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py create mode 100644 tests_v2/test_command_palette_switcher_renamed_surface.py create mode 100644 tests_v2/test_command_palette_switcher_surface_precedence.py create mode 100644 tests_v2/test_command_palette_switcher_type_labels.py create mode 100644 tests_v2/test_command_palette_window_scope.py create mode 100644 tests_v2/test_shortcut_window_scope.py diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f9003b88..419276d6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -103,12 +103,57 @@ func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { return normalizedFlags == [] || normalizedFlags == [.shift] } +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let normalizedChars = chars.lowercased() + + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break + } + } + + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + if keyCode == 38 || normalizedChars == "j" || normalizedChars == "\u{0a}" { return 1 } // Ctrl+J + if keyCode == 40 || normalizedChars == "k" || normalizedChars == "\u{0b}" { return -1 } // Ctrl+K + } + + return nil +} + enum BrowserZoomShortcutAction: Equatable { case zoomIn case zoomOut case reset } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int +} + +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] + + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) +} + func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, @@ -337,6 +382,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] private var mainWindowControllers: [MainWindowController] = [] + private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] + private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] var updateViewModel: UpdateViewModel { updateController.viewModel @@ -563,6 +611,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } + commandPaletteVisibilityByWindowId[windowId] = false + commandPaletteSelectionByWindowId[windowId] = 0 + commandPaletteSnapshotByWindowId[windowId] = .empty if window.isKeyWindow { setActiveMainWindow(window) @@ -599,6 +650,111 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) + } + + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteVisibilityByWindowId[windowId] = visible + } + + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSelectionByWindowId[windowId] = max(0, index) + } + + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteSelectionByWindowId[windowId] ?? 0 + } + + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSnapshotByWindowId[windowId] = snapshot + } + + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteSnapshotByWindowId[windowId] ?? .empty + } + + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) + } + + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) + } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true + } + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) + } + return false + } + + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + if responder is GhosttyNSView || responder is WKWebView { + return true + } + + if let textView = responder as? NSTextView, + !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView { + return isTerminalOrBrowserView(delegateView) + } + + if let view = responder as? NSView { + return isTerminalOrBrowserView(view) + } + + return false + } + + private func isTerminalOrBrowserView(_ view: NSView) -> Bool { + if view is GhosttyNSView || view is WKWebView { + return true + } + var current: NSView? = view.superview + while let candidate = current { + if candidate is GhosttyNSView || candidate is WKWebView { + return true + } + current = candidate.superview + } + return false + } + + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview + } + return false + } + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { for ctx in mainWindowContexts.values { for ws in ctx.tabManager.tabs { @@ -656,6 +812,99 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId + } + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) + } + + private func activeCommandPaletteWindow() -> NSWindow? { + if let keyWindow = NSApp.keyWindow, + let windowId = mainWindowId(for: keyWindow), + commandPaletteVisibilityByWindowId[windowId] == true { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, + let windowId = mainWindowId(for: mainWindow), + commandPaletteVisibilityByWindowId[windowId] == true { + return mainWindow + } + if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(visibleWindowId) + } + return nil + } + + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] + } + + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + if let context = contextForMainWindow(event.window) { + return context + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return context + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context + } + return mainWindowContexts.values.first + } + + private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { + guard let context = preferredMainWindowContextForShortcuts(event: event), + let window = context.window ?? windowForMainWindowId(context.windowId) else { return } + setActiveMainWindow(window) + } + + @discardableResult + func toggleSidebarInActiveMainWindow() -> Bool { + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { + setActiveMainWindow(window) + } + activeContext.sidebarState.toggle() + return true + } + if let keyContext = contextForMainWindow(NSApp.keyWindow) { + if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { + setActiveMainWindow(window) + } + keyContext.sidebarState.toggle() + return true + } + if let mainContext = contextForMainWindow(NSApp.mainWindow) { + if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { + setActiveMainWindow(window) + } + mainContext.sidebarState.toggle() + return true + } + if let fallbackContext = mainWindowContexts.values.first { + if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { + setActiveMainWindow(window) + } + fallbackContext.sidebarState.toggle() + return true + } + if let sidebarState { + sidebarState.toggle() + return true + } + return false + } + + func sidebarVisibility(windowId: UUID) -> Bool? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -1865,7 +2114,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - let normalizedFlags = flags.subtracting([.numericPad, .function]) + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + + if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ), + let paletteWindow = activeCommandPaletteWindow() { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] + ) + return true + } + + let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35) + if isCommandP { + let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + return true + } + + let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) + if isCommandShiftP { + let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + return true + } + if normalizedFlags == [.command], chars == "q" { return handleQuitShortcutWarning() } @@ -1895,6 +2173,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Route all shortcut handling through the window that actually produced + // the event to avoid cross-window actions when app-global pointers are stale. + activateMainWindowContextForShortcutEvent(event) + // Keep keyboard routing deterministic after split close/reparent transitions: // before processing shortcuts, converge first responder with the focused terminal panel. if isControlD { @@ -1942,7 +2224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Primary UI shortcuts if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) { - sidebarState?.toggle() + _ = toggleSidebarInActiveMainWindow() return true } @@ -2926,6 +3208,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func unregisterMainWindow(_ window: NSWindow) { let key = ObjectIdentifier(window) guard let removed = mainWindowContexts.removeValue(forKey: key) else { return } + commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { @@ -3873,6 +4158,19 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView? private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + if let responder, let webView = Self.cmuxOwningWebView(for: responder), !webView.allowsFirstResponderAcquisitionEffective { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7a8cb326..8359f265 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -718,6 +718,272 @@ final class FileDropOverlayView: NSView { } var fileDropOverlayKey: UInt8 = 0 +private var commandPaletteWindowOverlayKey: UInt8 = 0 +let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") + +@MainActor +private final class CommandPaletteOverlayContainerView: NSView { + var capturesMouseEvents = false + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard capturesMouseEvents else { return nil } + return super.hitTest(point) + } +} + +@MainActor +private final class WindowCommandPaletteOverlayController: NSObject { + private weak var window: NSWindow? + private let containerView = CommandPaletteOverlayContainerView(frame: .zero) + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private var installConstraints: [NSLayoutConstraint] = [] + private weak var installedThemeFrame: NSView? + private var focusLockTimer: DispatchSourceTimer? + private var scheduledFocusWorkItem: DispatchWorkItem? + + init(window: NSWindow) { + self.window = window + super.init() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.isHidden = true + containerView.alphaValue = 0 + containerView.capturesMouseEvents = false + containerView.identifier = commandPaletteOverlayContainerIdentifier + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return false } + + if containerView.superview !== themeFrame { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + installConstraints = [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + installedThemeFrame = themeFrame + } else if themeFrame.subviews.last !== containerView { + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + return true + } + + private func isPaletteResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let view = responder as? NSView, view.isDescendant(of: containerView) { + return true + } + + if let textView = responder as? NSTextView { + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + } + + return false + } + + private func isPaletteFieldEditor(_ textView: NSTextView) -> Bool { + guard textView.isFieldEditor else { return false } + + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + + // SwiftUI text fields can keep a field editor delegate that isn't an NSView. + // Fall back to validating editor ownership from the mounted palette text field. + if let textField = firstEditableTextField(in: hostingView), + textField.currentEditor() === textView { + return true + } + + return false + } + + private func isPaletteTextInputFirstResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let textView = responder as? NSTextView { + return isPaletteFieldEditor(textView) + } + + if let textField = responder as? NSTextField { + return textField.isDescendant(of: containerView) + } + + return false + } + + private func firstEditableTextField(in view: NSView) -> NSTextField? { + if let textField = view as? NSTextField, + textField.isEditable, + textField.isEnabled, + !textField.isHiddenOrHasHiddenAncestor { + return textField + } + + for subview in view.subviews { + if let match = firstEditableTextField(in: subview) { + return match + } + } + return nil + } + + private func scheduleFocusIntoPalette(retries: Int = 4) { + scheduledFocusWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.scheduledFocusWorkItem = nil + self?.focusIntoPalette(retries: retries) + } + scheduledFocusWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func focusIntoPalette(retries: Int) { + guard let window else { return } + if isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + + if window.makeFirstResponder(containerView) { + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + } + + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.focusIntoPalette(retries: retries - 1) + } + } + + private func startFocusLockTimer() { + guard focusLockTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12)) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard let window = self.window else { + self.stopFocusLockTimer() + return + } + if self.isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + self.focusIntoPalette(retries: 1) + } + focusLockTimer = timer + timer.resume() + } + + private func stopFocusLockTimer() { + focusLockTimer?.cancel() + focusLockTimer = nil + scheduledFocusWorkItem?.cancel() + scheduledFocusWorkItem = nil + } + + private func normalizeSelectionAfterProgrammaticFocus() { + guard let window, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } + + let text = editor.string + let length = (text as NSString).length + let selection = editor.selectedRange() + guard length > 0 else { return } + guard selection.location == 0, selection.length == length else { return } + + // Keep commands-mode prefix semantics stable after focus re-assertions: + // if AppKit selected the entire query (e.g. ">foo"), restore caret-at-end + // so the next keystroke appends instead of replacing and switching modes. + guard text.hasPrefix(">") else { return } + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + + func update(rootView: AnyView, isVisible: Bool) { + guard ensureInstalled() else { return } + if isVisible { + hostingView.rootView = rootView + containerView.capturesMouseEvents = true + containerView.isHidden = false + containerView.alphaValue = 1 + if let themeFrame = installedThemeFrame, themeFrame.subviews.last !== containerView { + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + startFocusLockTimer() + if let window, !isPaletteTextInputFirstResponder(window.firstResponder) { + scheduleFocusIntoPalette(retries: 8) + } + } else { + stopFocusLockTimer() + if let window, isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + hostingView.rootView = AnyView(EmptyView()) + containerView.capturesMouseEvents = false + containerView.alphaValue = 0 + containerView.isHidden = true + } + } +} + +@MainActor +private func commandPaletteWindowOverlayController(for window: NSWindow) -> WindowCommandPaletteOverlayController { + if let existing = objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController { + return existing + } + let controller = WindowCommandPaletteOverlayController(window: window) + objc_setAssociatedObject(window, &commandPaletteWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller +} + +private struct CommandPaletteRowFramePreferenceKey: PreferenceKey { + static var defaultValue: [Int: CGRect] = [:] + + static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs }) + } +} enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. @@ -848,11 +1114,226 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? + @State private var isCommandPalettePresented = false + @State private var commandPaletteQuery: String = "" + @State private var commandPaletteMode: CommandPaletteMode = .commands + @State private var commandPaletteRenameDraft: String = "" + @State private var commandPaletteSelectedResultIndex: Int = 0 + @State private var commandPaletteHoveredResultIndex: Int? + @State private var commandPaletteLastSelectionIndex: Int = 0 + @State private var commandPaletteRowFrames: [Int: CGRect] = [:] + @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @FocusState private var isCommandPaletteSearchFocused: Bool + @FocusState private var isCommandPaletteRenameFocused: Bool + + private enum CommandPaletteMode { + case commands + case renameInput(CommandPaletteRenameTarget) + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + } + + private enum CommandPaletteListScope: String { + case commands + case switcher + } + + private struct CommandPaletteRenameTarget: Equatable { + enum Kind: Equatable { + case workspace(workspaceId: UUID) + case tab(workspaceId: UUID, panelId: UUID) + } + + let kind: Kind + let currentName: String + + var title: String { + switch kind { + case .workspace: + return "Rename Workspace" + case .tab: + return "Rename Tab" + } + } + + var description: String { + switch kind { + case .workspace: + return "Choose a custom workspace name." + case .tab: + return "Choose a custom tab name." + } + } + + var placeholder: String { + switch kind { + case .workspace: + return "Workspace name" + case .tab: + return "Tab name" + } + } + } + + private struct CommandPaletteRestoreFocusTarget { + let workspaceId: UUID + let panelId: UUID + } + + private enum CommandPaletteInputFocusTarget { + case search + case rename + } + + private enum CommandPaletteTextSelectionBehavior { + case caretAtEnd + case selectAll + } + + private enum CommandPaletteTrailingLabelStyle { + case shortcut + case kind + } + + enum CommandPaletteScrollAnchor: Equatable { + case top + case bottom + } + + private struct CommandPaletteTrailingLabel { + let text: String + let style: CommandPaletteTrailingLabelStyle + } + + private struct CommandPaletteInputFocusPolicy { + let focusTarget: CommandPaletteInputFocusTarget + let selectionBehavior: CommandPaletteTextSelectionBehavior + + static let search = CommandPaletteInputFocusPolicy( + focusTarget: .search, + selectionBehavior: .caretAtEnd + ) + } + + private struct CommandPaletteCommand: Identifiable { + let id: String + let rank: Int + let title: String + let subtitle: String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let action: () -> Void + + var searchableTexts: [String] { + [title, subtitle] + keywords + } + } + + private struct CommandPaletteUsageEntry: Codable { + var useCount: Int + var lastUsedAt: TimeInterval + } + + private struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + mutating func setBool(_ key: String, _ value: Bool) { + boolValues[key] = value + } + + mutating func setString(_ key: String, _ value: String?) { + guard let value, !value.isEmpty else { + stringValues.removeValue(forKey: key) + return + } + stringValues[key] = value + } + + func bool(_ key: String) -> Bool { + boolValues[key] ?? false + } + + func string(_ key: String) -> String? { + stringValues[key] + } + } + + private enum CommandPaletteContextKeys { + static let hasWorkspace = "workspace.hasSelection" + static let workspaceName = "workspace.name" + static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceShouldPin = "workspace.shouldPin" + + static let hasFocusedPanel = "panel.hasFocus" + static let panelName = "panel.name" + static let panelIsBrowser = "panel.isBrowser" + static let panelIsTerminal = "panel.isTerminal" + static let panelHasCustomName = "panel.hasCustomName" + static let panelShouldPin = "panel.shouldPin" + static let panelHasUnread = "panel.hasUnread" + } + + private struct CommandPaletteCommandContribution { + let commandId: String + let title: (CommandPaletteContextSnapshot) -> String + let subtitle: (CommandPaletteContextSnapshot) -> String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let when: (CommandPaletteContextSnapshot) -> Bool + let enablement: (CommandPaletteContextSnapshot) -> Bool + + init( + commandId: String, + title: @escaping (CommandPaletteContextSnapshot) -> String, + subtitle: @escaping (CommandPaletteContextSnapshot) -> String, + shortcutHint: String? = nil, + keywords: [String] = [], + dismissOnRun: Bool = true, + when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, + enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } + ) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.when = when + self.enablement = enablement + } + } + + private struct CommandPaletteHandlerRegistry { + private var handlers: [String: () -> Void] = [:] + + mutating func register(commandId: String, handler: @escaping () -> Void) { + handlers[commandId] = handler + } + + func handler(for commandId: String) -> (() -> Void)? { + handlers[commandId] + } + } + + private struct CommandPaletteSearchResult: Identifiable { + let command: CommandPaletteCommand + let score: Int + let titleMatchIndices: Set + + var id: String { command.id } + } private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot ) + private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" + private static let commandPaletteCommandsPrefix = ">" private enum SidebarResizerHandle: Hashable { case divider @@ -1193,7 +1674,7 @@ struct ContentView: View { TitlebarControlsView( notificationStore: TerminalNotificationStore.shared, viewModel: fullscreenControlsViewModel, - onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() }, + onToggleSidebar: { sidebarState.toggle() }, onToggleNotifications: { [fullscreenControlsViewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, @@ -1367,6 +1848,7 @@ struct ContentView: View { var body: some View { var view = AnyView( contentAndSidebarLayout + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible { fullscreenControls @@ -1496,6 +1978,97 @@ struct ContentView: View { #endif }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteToggleRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + toggleCommandPalette() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteCommands() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSwitcherRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteSwitcher() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteRenameTabInput() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteMoveSelection)) { notification in + guard isCommandPalettePresented else { return } + guard case .commands = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } + moveCommandPaletteSelection(by: delta) + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputInteractionRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + handleCommandPaletteRenameInputInteraction() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputDeleteBackwardRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + _ = handleCommandPaletteRenameDeleteBackward(modifiers: []) + }) + + view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in + MainActor.assumeIsolated { + let overlayController = commandPaletteWindowOverlayController(for: window) + overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) + } + })) + view = AnyView(view.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }) @@ -1547,6 +2120,7 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) + syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() } @@ -1748,6 +2322,1956 @@ struct ContentView: View { #endif } + private var commandPaletteOverlay: some View { + GeometryReader { proxy in + let maxAllowedWidth = max(340, proxy.size.width - 260) + let targetWidth = min(560, maxAllowedWidth) + + ZStack(alignment: .top) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + dismissCommandPalette() + } + + VStack(spacing: 0) { + switch commandPaletteMode { + case .commands: + commandPaletteCommandListView + case .renameInput(let target): + commandPaletteRenameInputView(target: target) + case let .renameConfirm(target, proposedName): + commandPaletteRenameConfirmView(target: target, proposedName: proposedName) + } + } + .frame(width: targetWidth) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.98)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 5) + .padding(.top, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onExitCommand { + dismissCommandPalette() + } + .zIndex(2000) + } + + private var commandPaletteCommandListView: some View { + let visibleResults = Array(commandPaletteResults) + let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let commandPaletteListMaxHeight: CGFloat = 216 + let commandPaletteRowHeight: CGFloat = 24 + let commandPaletteEmptyStateHeight: CGFloat = 44 + let commandPaletteListContentHeight = visibleResults.isEmpty + ? commandPaletteEmptyStateHeight + : CGFloat(visibleResults.count) * commandPaletteRowHeight + let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) + return VStack(spacing: 0) { + HStack(spacing: 8) { + TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(.blue) + .focused($isCommandPaletteSearchFocused) + .onSubmit { + runSelectedCommandPaletteResult(visibleResults: visibleResults) + } + .backport.onKeyPress(.downArrow) { _ in + moveCommandPaletteSelection(by: 1) + return .handled + } + .backport.onKeyPress(.upArrow) { _ in + moveCommandPaletteSelection(by: -1) + return .handled + } + .backport.onKeyPress("n") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("p") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + .backport.onKeyPress("j") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("k") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? Color.accentColor.opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) + + Button { + runCommandPaletteCommand(result.command) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices + ) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: CommandPaletteRowFramePreferenceKey.self, + value: [index: geometry.frame(in: .named("commandPaletteListScroll"))] + ) + } + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } + } + } + } + } + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .coordinateSpace(name: "commandPaletteListScroll") + .frame(height: commandPaletteListHeight) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + guard !visibleResults.isEmpty else { return } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let previousIndex = commandPaletteLastSelectionIndex + defer { commandPaletteLastSelectionIndex = index } + + guard let anchorDecision = Self.commandPaletteScrollAnchor( + selectedIndex: index, + previousIndex: previousIndex, + resultCount: visibleResults.count, + selectedFrame: commandPaletteRowFrames[index], + viewportHeight: commandPaletteListHeight, + contentHeight: commandPaletteListContentHeight + ) else { return } + + let anchor: UnitPoint + switch anchorDecision { + case .top: + anchor = .top + case .bottom: + anchor = .bottom + } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(index, anchor: anchor) + } + } + } + .onChange(of: visibleResults.count) { _ in + commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + } + .onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in + commandPaletteRowFrames = frames + guard !visibleResults.isEmpty else { return } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: index, + resultCount: visibleResults.count, + selectedFrame: frames[index], + viewportHeight: commandPaletteListHeight, + contentHeight: commandPaletteListContentHeight + ) else { return } + let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.08)) { + proxy.scrollTo(index, anchor: anchor) + } + } + } + } + + // Keep Esc-to-close behavior without showing footer controls. + Button(action: { dismissCommandPalette() }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + commandPaletteRowFrames = [:] + resetCommandPaletteSearchFocus() + } + .onChange(of: commandPaletteQuery) { _ in + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = 0 + commandPaletteRowFrames = [:] + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: visibleResults.count) { _ in + commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { + commandPaletteHoveredResultIndex = nil + } + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteSelectedResultIndex) { _ in + syncCommandPaletteDebugStateForObservedWindow() + } + } + + private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View { + VStack(spacing: 0) { + TextField(target.placeholder, text: $commandPaletteRenameDraft) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(.blue) + .focused($isCommandPaletteRenameFocused) + .backport.onKeyPress(.delete) { modifiers in + handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) + } + .onSubmit { + continueRenameFlow(target: target) + } + .onTapGesture { + handleCommandPaletteRenameInputInteraction() + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text("Enter a \(renameTargetNoun(target)) name. Press Enter to rename, Escape to cancel.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + continueRenameFlow(target: target) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + resetCommandPaletteRenameFocus() + } + } + + private func commandPaletteRenameConfirmView( + target: CommandPaletteRenameTarget, + proposedName: String + ) -> some View { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let nextName = trimmedName.isEmpty ? "(clear custom name)" : trimmedName + + return VStack(spacing: 0) { + Text(nextName) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text("Press Enter to apply this \(renameTargetNoun(target)) name, or Escape to cancel.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + applyRenameFlow(target: target, proposedName: proposedName) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + } + + private func renameTargetNoun(_ target: CommandPaletteRenameTarget) -> String { + switch target.kind { + case .workspace: + return "workspace" + case .tab: + return "tab" + } + } + + private var commandPaletteListScope: CommandPaletteListScope { + if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) { + return .commands + } + return .switcher + } + + private var commandPaletteSearchPlaceholder: String { + switch commandPaletteListScope { + case .commands: + return "Type a command" + case .switcher: + return "Search workspaces and tabs" + } + } + + private var commandPaletteEmptyStateText: String { + switch commandPaletteListScope { + case .commands: + return "No commands match your search." + case .switcher: + return "No workspaces or tabs match your search." + } + } + + private var commandPaletteQueryForMatching: String { + switch commandPaletteListScope { + case .commands: + let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count)) + return suffix.trimmingCharacters(in: .whitespacesAndNewlines) + case .switcher: + return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private var commandPaletteEntries: [CommandPaletteCommand] { + switch commandPaletteListScope { + case .commands: + return commandPaletteCommands() + case .switcher: + return commandPaletteSwitcherEntries() + } + } + + private var commandPaletteResults: [CommandPaletteSearchResult] { + let entries = commandPaletteEntries + let query = commandPaletteQueryForMatching + let queryIsEmpty = query.isEmpty + + let results: [CommandPaletteSearchResult] = queryIsEmpty + ? entries.map { entry in + CommandPaletteSearchResult( + command: entry, + score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true), + titleMatchIndices: [] + ) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else { + return nil + } + return CommandPaletteSearchResult( + command: entry, + score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank } + return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending + } + } + + private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { + guard !matchedIndices.isEmpty else { + return Text(title).foregroundColor(.primary) + } + + let chars = Array(title) + var index = 0 + var result = Text("") + + while index < chars.count { + let isMatched = matchedIndices.contains(index) + var end = index + 1 + while end < chars.count, matchedIndices.contains(end) == isMatched { + end += 1 + } + + let segment = String(chars[index.. CommandPaletteTrailingLabel? { + if let shortcutHint = command.shortcutHint { + return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut) + } + + guard commandPaletteListScope == .switcher else { return nil } + if command.id.hasPrefix("switcher.workspace.") { + return CommandPaletteTrailingLabel(text: "Workspace", style: .kind) + } + if command.id.hasPrefix("switcher.surface.") { + return CommandPaletteTrailingLabel(text: "Surface", style: .kind) + } + return nil + } + + private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { + var workspaces = tabManager.tabs + guard !workspaces.isEmpty else { return [] } + + if let selectedWorkspaceId = tabManager.selectedTabId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) + } + + var entries: [CommandPaletteCommand] = [] + entries.reserveCapacity(workspaces.count * 4) + var nextRank = 0 + + for workspace in workspaces { + let workspaceName = workspaceDisplayName(workspace) + let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "workspace", + "switch", + "go", + "open", + workspaceName + ], + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + detail: .workspace + ) + entries.append( + CommandPaletteCommand( + id: workspaceCommandId, + rank: nextRank, + title: workspaceName, + subtitle: "Workspace", + shortcutHint: nil, + keywords: workspaceKeywords, + dismissOnRun: true, + action: { + tabManager.focusTab(workspace.id, suppressFlash: true) + } + ) + ) + nextRank += 1 + + var orderedPanelIds = workspace.sidebarOrderedPanelIds() + if let focusedPanelId = workspace.focusedPanelId, + let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { + orderedPanelIds.remove(at: focusedIndex) + orderedPanelIds.insert(focusedPanelId, at: 0) + } + + for panelId in orderedPanelIds { + guard let panel = workspace.panels[panelId] else { continue } + let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) + let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" + let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "tab", + "surface", + "panel", + "switch", + "go", + workspaceName, + panelTitle, + typeLabel.lowercased() + ], + metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + ) + entries.append( + CommandPaletteCommand( + id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + rank: nextRank, + title: panelTitle, + subtitle: "\(typeLabel) • \(workspaceName)", + shortcutHint: nil, + keywords: panelKeywords, + dismissOnRun: true, + action: { + tabManager.focusTab(workspace.id, surfaceId: panelId, suppressFlash: true) + } + ) + ) + nextRank += 1 + } + } + + return entries + } + + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { + // Keep workspace rows coarse so surface rows win for directory/branch-specific queries. + let directories = [workspace.currentDirectory] + let branches = [workspace.gitBranch?.branch].compactMap { $0 } + let ports = workspace.listeningPorts + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPalettePanelSearchMetadata(in workspace: Workspace, panelId: UUID) -> CommandPaletteSwitcherSearchMetadata { + var directories: [String] = [] + if let directory = workspace.panelDirectories[panelId] { + directories.append(directory) + } else if workspace.focusedPanelId == panelId { + directories.append(workspace.currentDirectory) + } + + var branches: [String] = [] + if let branch = workspace.panelGitBranches[panelId]?.branch { + branches.append(branch) + } else if workspace.focusedPanelId == panelId, let branch = workspace.gitBranch?.branch { + branches.append(branch) + } + + var ports = workspace.surfaceListeningPorts[panelId] ?? [] + if ports.isEmpty, workspace.panels.count == 1 { + ports = workspace.listeningPorts + } + + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPaletteCommands() -> [CommandPaletteCommand] { + let context = commandPaletteContextSnapshot() + let contributions = commandPaletteCommandContributions() + var handlerRegistry = CommandPaletteHandlerRegistry() + registerCommandPaletteHandlers(&handlerRegistry) + + var commands: [CommandPaletteCommand] = [] + commands.reserveCapacity(contributions.count) + var nextRank = 0 + + for contribution in contributions { + guard contribution.when(context), contribution.enablement(context) else { continue } + guard let action = handlerRegistry.handler(for: contribution.commandId) else { + assertionFailure("No command palette handler registered for \(contribution.commandId)") + continue + } + commands.append( + CommandPaletteCommand( + id: contribution.commandId, + rank: nextRank, + title: contribution.title(context), + subtitle: contribution.subtitle(context), + shortcutHint: commandPaletteShortcutHint(for: contribution), + keywords: contribution.keywords, + dismissOnRun: contribution.dismissOnRun, + action: action + ) + ) + nextRank += 1 + } + + return commands + } + + private func commandPaletteShortcutHint(for contribution: CommandPaletteCommandContribution) -> String? { + if let action = commandPaletteShortcutAction(for: contribution.commandId) { + return KeyboardShortcutSettings.shortcut(for: action).displayString + } + if let staticShortcut = commandPaletteStaticShortcutHint(for: contribution.commandId) { + return staticShortcut + } + return contribution.shortcutHint + } + + private func commandPaletteShortcutAction(for commandId: String) -> KeyboardShortcutSettings.Action? { + switch commandId { + case "palette.newWorkspace": + return .newTab + case "palette.newTerminalTab": + return .newSurface + case "palette.newBrowserTab": + return .openBrowser + case "palette.toggleSidebar": + return .toggleSidebar + case "palette.showNotifications": + return .showNotifications + case "palette.jumpUnread": + return .jumpToUnread + case "palette.renameWorkspace": + return .renameWorkspace + case "palette.nextWorkspace": + return .nextSidebarTab + case "palette.previousWorkspace": + return .prevSidebarTab + case "palette.nextTabInPane": + return .nextSurface + case "palette.previousTabInPane": + return .prevSurface + case "palette.browserToggleDevTools": + return .toggleBrowserDeveloperTools + case "palette.browserConsole": + return .showBrowserJavaScriptConsole + case "palette.browserSplitRight", "palette.terminalSplitBrowserRight": + return .splitBrowserRight + case "palette.browserSplitDown", "palette.terminalSplitBrowserDown": + return .splitBrowserDown + case "palette.terminalSplitRight": + return .splitRight + case "palette.terminalSplitDown": + return .splitDown + default: + return nil + } + } + + private func commandPaletteStaticShortcutHint(for commandId: String) -> String? { + switch commandId { + case "palette.closeTab": + return "⌘W" + case "palette.closeWorkspace": + return "⌘⇧W" + case "palette.reopenClosedBrowserTab": + return "⌘⇧T" + case "palette.openSettings": + return "⌘," + case "palette.browserBack": + return "⌘[" + case "palette.browserForward": + return "⌘]" + case "palette.browserReload": + return "⌘R" + case "palette.browserFocusAddressBar": + return "⌘L" + case "palette.browserZoomIn": + return "⌘=" + case "palette.browserZoomOut": + return "⌘-" + case "palette.browserZoomReset": + return "⌘0" + case "palette.terminalFind": + return "⌘F" + case "palette.terminalFindNext": + return "⌘G" + case "palette.terminalFindPrevious": + return "⌘⇧G" + case "palette.terminalHideFind": + return "⌘⇧F" + case "palette.terminalUseSelectionForFind": + return "⌘E" + default: + return nil + } + } + + private func commandPaletteContextSnapshot() -> CommandPaletteContextSnapshot { + var snapshot = CommandPaletteContextSnapshot() + + if let workspace = tabManager.selectedWorkspace { + snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true) + snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) + snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) + } + + if let panelContext = focusedPanelContext { + let workspace = panelContext.workspace + let panelId = panelContext.panelId + snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) + snapshot.setString( + CommandPaletteContextKeys.panelName, + panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) + ) + snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal) + snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) + snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) + let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) + || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) + snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + } + + return snapshot + } + + private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { + func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { + { _ in value } + } + + func workspaceSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.workspaceName) ?? "Workspace" + return "Workspace • \(name)" + } + + func panelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Tab • \(name)" + } + + func browserPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Browser • \(name)" + } + + func terminalPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Terminal • \(name)" + } + + var contributions: [CommandPaletteCommandContribution] = [] + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWorkspace", + title: constant("New Workspace"), + subtitle: constant("Workspace"), + keywords: ["create", "new", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newTerminalTab", + title: constant("New Tab (Terminal)"), + subtitle: constant("Tab"), + shortcutHint: "⌘T", + keywords: ["new", "terminal", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newBrowserTab", + title: constant("New Tab (Browser)"), + subtitle: constant("Tab"), + shortcutHint: "⌘⇧L", + keywords: ["new", "browser", "tab", "web"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeTab", + title: constant("Close Tab"), + subtitle: constant("Tab"), + shortcutHint: "⌘W", + keywords: ["close", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspace", + title: constant("Close Workspace"), + subtitle: constant("Workspace"), + shortcutHint: "⌘⇧W", + keywords: ["close", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.reopenClosedBrowserTab", + title: constant("Reopen Closed Browser Tab"), + subtitle: constant("Browser"), + shortcutHint: "⌘⇧T", + keywords: ["reopen", "closed", "browser"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleSidebar", + title: constant("Toggle Sidebar"), + subtitle: constant("Layout"), + keywords: ["toggle", "sidebar", "layout"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.showNotifications", + title: constant("Show Notifications"), + subtitle: constant("Notifications"), + keywords: ["notifications", "inbox"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.jumpUnread", + title: constant("Jump to Latest Unread"), + subtitle: constant("Notifications"), + keywords: ["jump", "unread", "notification"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openSettings", + title: constant("Open Settings"), + subtitle: constant("Global"), + shortcutHint: "⌘,", + keywords: ["settings", "preferences"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.checkForUpdates", + title: constant("Check for Updates"), + subtitle: constant("Global"), + keywords: ["update", "upgrade", "release"] + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameWorkspace", + title: constant("Rename Workspace…"), + subtitle: workspaceSubtitle, + keywords: ["rename", "workspace", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearWorkspaceName", + title: constant("Clear Workspace Name"), + subtitle: workspaceSubtitle, + keywords: ["clear", "workspace", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) + && $0.bool(CommandPaletteContextKeys.workspaceHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleWorkspacePin", + title: { context in + context.bool(CommandPaletteContextKeys.workspaceShouldPin) ? "Pin Workspace" : "Unpin Workspace" + }, + subtitle: workspaceSubtitle, + keywords: ["workspace", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextWorkspace", + title: constant("Next Workspace"), + subtitle: constant("Workspace Navigation"), + keywords: ["next", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousWorkspace", + title: constant("Previous Workspace"), + subtitle: constant("Workspace Navigation"), + keywords: ["previous", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameTab", + title: constant("Rename Tab…"), + subtitle: panelSubtitle, + keywords: ["rename", "tab", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearTabName", + title: constant("Clear Tab Name"), + subtitle: panelSubtitle, + keywords: ["clear", "tab", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasFocusedPanel) + && $0.bool(CommandPaletteContextKeys.panelHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabPin", + title: { context in + context.bool(CommandPaletteContextKeys.panelShouldPin) ? "Pin Tab" : "Unpin Tab" + }, + subtitle: panelSubtitle, + keywords: ["tab", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabUnread", + title: { context in + context.bool(CommandPaletteContextKeys.panelHasUnread) ? "Mark Tab as Read" : "Mark Tab as Unread" + }, + subtitle: panelSubtitle, + keywords: ["tab", "read", "unread", "notification"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextTabInPane", + title: constant("Next Tab in Pane"), + subtitle: constant("Tab Navigation"), + keywords: ["next", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousTabInPane", + title: constant("Previous Tab in Pane"), + subtitle: constant("Tab Navigation"), + keywords: ["previous", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserBack", + title: constant("Back"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘[", + keywords: ["browser", "back", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserForward", + title: constant("Forward"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘]", + keywords: ["browser", "forward", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserReload", + title: constant("Reload Page"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘R", + keywords: ["browser", "reload", "refresh"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserOpenDefault", + title: constant("Open Current Page in Default Browser"), + subtitle: browserPanelSubtitle, + keywords: ["open", "default", "external", "browser"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserFocusAddressBar", + title: constant("Focus Address Bar"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘L", + keywords: ["browser", "address", "omnibar", "url"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserToggleDevTools", + title: constant("Toggle Developer Tools"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "devtools", "inspector"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserConsole", + title: constant("Show JavaScript Console"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "console", "javascript"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomIn", + title: constant("Zoom In"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "in"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomOut", + title: constant("Zoom Out"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "out"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomReset", + title: constant("Actual Size"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "reset", "actual size"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserClearHistory", + title: constant("Clear Browser History"), + subtitle: constant("Browser"), + keywords: ["browser", "history", "clear"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitRight", + title: constant("Split Browser Right"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitDown", + title: constant("Split Browser Down"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserDuplicateRight", + title: constant("Duplicate Browser to the Right"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "duplicate", "clone", "split"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalOpenDirectory", + title: constant("Open Current Directory in IDE"), + subtitle: terminalPanelSubtitle, + keywords: ["terminal", "directory", "open", "ide", "code", "default app"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFind", + title: constant("Find…"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘F", + keywords: ["terminal", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindNext", + title: constant("Find Next"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘G", + keywords: ["terminal", "find", "next", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindPrevious", + title: constant("Find Previous"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧G", + keywords: ["terminal", "find", "previous", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalHideFind", + title: constant("Hide Find Bar"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧F", + keywords: ["terminal", "hide", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalUseSelectionForFind", + title: constant("Use Selection for Find"), + subtitle: terminalPanelSubtitle, + keywords: ["terminal", "selection", "find"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitRight", + title: constant("Split Right"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitDown", + title: constant("Split Down"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserRight", + title: constant("Split Browser Right"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "browser", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserDown", + title: constant("Split Browser Down"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "browser", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + + return contributions + } + + private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) { + registry.register(commandId: "palette.newWorkspace") { + tabManager.addWorkspace() + } + registry.register(commandId: "palette.newTerminalTab") { + tabManager.newSurface() + } + registry.register(commandId: "palette.newBrowserTab") { + _ = tabManager.openBrowser() + } + registry.register(commandId: "palette.closeTab") { + tabManager.closeCurrentPanelWithConfirmation() + } + registry.register(commandId: "palette.closeWorkspace") { + tabManager.closeCurrentWorkspaceWithConfirmation() + } + registry.register(commandId: "palette.reopenClosedBrowserTab") { + _ = tabManager.reopenMostRecentlyClosedBrowserPanel() + } + registry.register(commandId: "palette.toggleSidebar") { + sidebarState.toggle() + } + registry.register(commandId: "palette.showNotifications") { + AppDelegate.shared?.toggleNotificationsPopover(animated: false) + } + registry.register(commandId: "palette.jumpUnread") { + AppDelegate.shared?.jumpToLatestUnread() + } + registry.register(commandId: "palette.openSettings") { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + registry.register(commandId: "palette.checkForUpdates") { + AppDelegate.shared?.checkForUpdates(nil) + } + + registry.register(commandId: "palette.renameWorkspace") { + beginRenameWorkspaceFlow() + } + registry.register(commandId: "palette.clearWorkspaceName") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.clearCustomTitle(tabId: workspace.id) + } + registry.register(commandId: "palette.toggleWorkspacePin") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.setPinned(workspace, pinned: !workspace.isPinned) + } + registry.register(commandId: "palette.nextWorkspace") { + tabManager.selectNextTab() + } + registry.register(commandId: "palette.previousWorkspace") { + tabManager.selectPreviousTab() + } + + registry.register(commandId: "palette.renameTab") { + beginRenameTabFlow() + } + registry.register(commandId: "palette.clearTabName") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelCustomTitle(panelId: panelContext.panelId, title: nil) + } + registry.register(commandId: "palette.toggleTabPin") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelPinned( + panelId: panelContext.panelId, + pinned: !panelContext.workspace.isPanelPinned(panelContext.panelId) + ) + } + registry.register(commandId: "palette.toggleTabUnread") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let hasUnread = panelContext.workspace.manualUnreadPanelIds.contains(panelContext.panelId) + || notificationStore.hasUnreadNotification(forTabId: panelContext.workspace.id, surfaceId: panelContext.panelId) + if hasUnread { + panelContext.workspace.markPanelRead(panelContext.panelId) + } else { + panelContext.workspace.markPanelUnread(panelContext.panelId) + } + } + registry.register(commandId: "palette.nextTabInPane") { + tabManager.selectNextSurface() + } + registry.register(commandId: "palette.previousTabInPane") { + tabManager.selectPreviousSurface() + } + + registry.register(commandId: "palette.browserBack") { + tabManager.focusedBrowserPanel?.goBack() + } + registry.register(commandId: "palette.browserForward") { + tabManager.focusedBrowserPanel?.goForward() + } + registry.register(commandId: "palette.browserReload") { + tabManager.focusedBrowserPanel?.reload() + } + registry.register(commandId: "palette.browserOpenDefault") { + if !openFocusedBrowserInDefaultBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserFocusAddressBar") { + if !focusFocusedBrowserAddressBar() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserToggleDevTools") { + if !tabManager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserConsole") { + if !tabManager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomIn") { + if !tabManager.zoomInFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomOut") { + if !tabManager.zoomOutFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomReset") { + if !tabManager.resetZoomFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserClearHistory") { + BrowserHistoryStore.shared.clearHistory() + } + registry.register(commandId: "palette.browserSplitRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.browserSplitDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + registry.register(commandId: "palette.browserDuplicateRight") { + let url = tabManager.focusedBrowserPanel?.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + _ = tabManager.createBrowserSplit(direction: .right, url: url) + } + + registry.register(commandId: "palette.terminalOpenDirectory") { + if !openFocusedDirectoryInDefaultApp() { + NSSound.beep() + } + } + registry.register(commandId: "palette.terminalFind") { + tabManager.startSearch() + } + registry.register(commandId: "palette.terminalFindNext") { + tabManager.findNext() + } + registry.register(commandId: "palette.terminalFindPrevious") { + tabManager.findPrevious() + } + registry.register(commandId: "palette.terminalHideFind") { + tabManager.hideFind() + } + registry.register(commandId: "palette.terminalUseSelectionForFind") { + tabManager.searchSelection() + } + registry.register(commandId: "palette.terminalSplitRight") { + tabManager.createSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitDown") { + tabManager.createSplit(direction: .down) + } + registry.register(commandId: "palette.terminalSplitBrowserRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitBrowserDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + } + + private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { + guard let workspace = tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let panel = workspace.panels[panelId] else { + return nil + } + return (workspace, panelId, panel) + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let custom = workspace.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !custom.isEmpty { + return custom + } + let title = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Workspace" : title + } + + private func panelDisplayName(workspace: Workspace, panelId: UUID, fallback: String) -> String { + let title = workspace.panelTitle(panelId: panelId)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title + } + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedFallback.isEmpty ? "Tab" : trimmedFallback + } + + private func commandPaletteSelectedIndex(resultCount: Int) -> Int { + guard resultCount > 0 else { return 0 } + return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) + } + + static func commandPaletteScrollAnchor( + selectedIndex: Int, + previousIndex: Int, + resultCount: Int, + selectedFrame: CGRect?, + viewportHeight: CGFloat, + contentHeight: CGFloat, + epsilon: CGFloat = 0.5 + ) -> CommandPaletteScrollAnchor? { + guard resultCount > 0 else { return nil } + guard contentHeight > viewportHeight else { return nil } + + // Always pin edges exactly into view when selection reaches first/last. + if selectedIndex <= 0 { + return .top + } + if selectedIndex >= resultCount - 1 { + return .bottom + } + + if let frame = selectedFrame, + frame.minY >= (0 - epsilon), + frame.maxY <= (viewportHeight + epsilon) { + return nil + } + + return selectedIndex >= previousIndex ? .bottom : .top + } + + static func commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: Int, + resultCount: Int, + selectedFrame: CGRect?, + viewportHeight: CGFloat, + contentHeight: CGFloat, + epsilon: CGFloat = 0.5 + ) -> CommandPaletteScrollAnchor? { + guard resultCount > 0 else { return nil } + guard contentHeight > viewportHeight else { return nil } + + let isTop = selectedIndex <= 0 + let isBottom = selectedIndex >= (resultCount - 1) + guard isTop || isBottom else { return nil } + + guard let frame = selectedFrame else { + return isTop ? .top : .bottom + } + + if isTop { + let topDelta = abs(frame.minY) + return topDelta > epsilon ? .top : nil + } + + let bottomDelta = abs(frame.maxY - viewportHeight) + return bottomDelta > epsilon ? .bottom : nil + } + + private func moveCommandPaletteSelection(by delta: Int) { + let count = commandPaletteResults.count + guard count > 0 else { + NSSound.beep() + return + } + let current = commandPaletteSelectedIndex(resultCount: count) + commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) + syncCommandPaletteDebugStateForObservedWindow() + } + + private func handleCommandPaletteControlNavigationKey( + modifiers: EventModifiers, + delta: Int + ) -> BackportKeyPressResult { + guard modifiers.contains(.control), + !modifiers.contains(.command), + !modifiers.contains(.shift), + !modifiers.contains(.option) else { + return .ignored + } + moveCommandPaletteSelection(by: delta) + return .handled + } + + static func commandPaletteShouldPopRenameInputOnDelete( + renameDraft: String, + modifiers: EventModifiers + ) -> Bool { + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return false } + return renameDraft.isEmpty + } + + private func handleCommandPaletteRenameDeleteBackward( + modifiers: EventModifiers + ) -> BackportKeyPressResult { + guard case .renameInput = commandPaletteMode else { return .ignored } + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return .ignored } + + if Self.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: commandPaletteRenameDraft, + modifiers: modifiers + ) { + commandPaletteMode = .commands + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + if let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor { + editor.deleteBackward(nil) + commandPaletteRenameDraft = editor.string + } else if !commandPaletteRenameDraft.isEmpty { + commandPaletteRenameDraft.removeLast() + } + + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { + let visibleResults = visibleResults ?? Array(commandPaletteResults) + guard !visibleResults.isEmpty else { + NSSound.beep() + return + } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + runCommandPaletteCommand(visibleResults[index].command) + } + + private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { + recordCommandPaletteUsage(command.id) + command.action() + if command.dismissOnRun { + dismissCommandPalette(restoreFocus: false) + } + } + + private func toggleCommandPalette() { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + } + + private func openCommandPaletteCommands() { + toggleCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + + private func openCommandPaletteSwitcher() { + toggleCommandPalette(initialQuery: "") + } + + private func toggleCommandPalette(initialQuery: String) { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: initialQuery) + } + } + + private func openCommandPaletteRenameTabInput() { + if !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + beginRenameTabFlow() + } + + static func shouldHandleCommandPaletteRequest( + observedWindow: NSWindow?, + requestedWindow: NSWindow?, + keyWindow: NSWindow?, + mainWindow: NSWindow? + ) -> Bool { + guard let observedWindow else { return false } + if let requestedWindow { + return requestedWindow === observedWindow + } + if let keyWindow { + return keyWindow === observedWindow + } + if let mainWindow { + return mainWindow === observedWindow + } + return false + } + + private func syncCommandPaletteDebugStateForObservedWindow() { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) + let visibleResultCount = commandPaletteResults.count + let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 + AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) + AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) + } + + private func commandPaletteDebugSnapshot() -> CommandPaletteDebugSnapshot { + guard isCommandPalettePresented else { return .empty } + + let mode: String + switch commandPaletteMode { + case .commands: + mode = commandPaletteListScope.rawValue + case .renameInput: + mode = "rename_input" + case .renameConfirm: + mode = "rename_confirm" + } + + let rows = Array(commandPaletteResults.prefix(20)).map { result in + CommandPaletteDebugResultRow( + commandId: result.command.id, + title: result.command.title, + shortcutHint: result.command.shortcutHint, + trailingLabel: commandPaletteTrailingLabel(for: result.command)?.text, + score: result.score + ) + } + + return CommandPaletteDebugSnapshot( + query: commandPaletteQueryForMatching, + mode: mode, + results: rows + ) + } + + private func presentCommandPalette(initialQuery: String) { + if let panelContext = focusedPanelContext { + commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget( + workspaceId: panelContext.workspace.id, + panelId: panelContext.panelId + ) + } else { + commandPaletteRestoreFocusTarget = nil + } + isCommandPalettePresented = true + refreshCommandPaletteUsageHistory() + resetCommandPaletteListState(initialQuery: initialQuery) + } + + private func resetCommandPaletteListState(initialQuery: String) { + commandPaletteMode = .commands + commandPaletteQuery = initialQuery + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = 0 + commandPaletteRowFrames = [:] + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func dismissCommandPalette(restoreFocus: Bool = true) { + let focusTarget = commandPaletteRestoreFocusTarget + isCommandPalettePresented = false + commandPaletteMode = .commands + commandPaletteQuery = "" + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = 0 + commandPaletteRowFrames = [:] + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = false + commandPaletteRestoreFocusTarget = nil + if let window = observedWindow { + _ = window.makeFirstResponder(nil) + } + syncCommandPaletteDebugStateForObservedWindow() + + guard restoreFocus, let focusTarget else { return } + restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + } + + private func restoreCommandPaletteFocus( + target: CommandPaletteRestoreFocusTarget, + attemptsRemaining: Int + ) { + guard !isCommandPalettePresented else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + + if let window = observedWindow, !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + guard !isCommandPalettePresented else { return } + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + return + } + restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func resetCommandPaletteSearchFocus() { + applyCommandPaletteInputFocusPolicy(.search) + } + + private func resetCommandPaletteRenameFocus() { + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func handleCommandPaletteRenameInputInteraction() { + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func commandPaletteRenameInputFocusPolicy() -> CommandPaletteInputFocusPolicy { + let selectAllOnFocus = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + let selectionBehavior: CommandPaletteTextSelectionBehavior = selectAllOnFocus + ? .selectAll + : .caretAtEnd + return CommandPaletteInputFocusPolicy( + focusTarget: .rename, + selectionBehavior: selectionBehavior + ) + } + + private func applyCommandPaletteInputFocusPolicy(_ policy: CommandPaletteInputFocusPolicy) { + DispatchQueue.main.async { + switch policy.focusTarget { + case .search: + isCommandPaletteRenameFocused = false + isCommandPaletteSearchFocused = true + case .rename: + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = true + } + applyCommandPaletteTextSelection(policy.selectionBehavior) + } + } + + private func applyCommandPaletteTextSelection( + _ behavior: CommandPaletteTextSelectionBehavior, + attemptsRemaining: Int = 20 + ) { + guard isCommandPalettePresented else { return } + switch behavior { + case .selectAll: + guard case .renameInput = commandPaletteMode else { return } + case .caretAtEnd: + switch commandPaletteMode { + case .commands, .renameInput: + break + case .renameConfirm: + return + } + } + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + + if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + return + } + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func refreshCommandPaletteUsageHistory() { + commandPaletteUsageHistoryByCommandId = loadCommandPaletteUsageHistory() + } + + private func loadCommandPaletteUsageHistory() -> [String: CommandPaletteUsageEntry] { + guard let data = UserDefaults.standard.data(forKey: Self.commandPaletteUsageDefaultsKey) else { + return [:] + } + return (try? JSONDecoder().decode([String: CommandPaletteUsageEntry].self, from: data)) ?? [:] + } + + private func persistCommandPaletteUsageHistory(_ history: [String: CommandPaletteUsageEntry]) { + guard let data = try? JSONEncoder().encode(history) else { return } + UserDefaults.standard.set(data, forKey: Self.commandPaletteUsageDefaultsKey) + } + + private func recordCommandPaletteUsage(_ commandId: String) { + var history = commandPaletteUsageHistoryByCommandId + var entry = history[commandId] ?? CommandPaletteUsageEntry(useCount: 0, lastUsedAt: 0) + entry.useCount += 1 + entry.lastUsedAt = Date().timeIntervalSince1970 + history[commandId] = entry + commandPaletteUsageHistoryByCommandId = history + persistCommandPaletteUsageHistory(history) + } + + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { + guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 } + + let now = Date().timeIntervalSince1970 + let ageDays = max(0, now - entry.lastUsedAt) / 86_400 + let recencyBoost = max(0, 320 - Int(ageDays * 20)) + let countBoost = min(180, entry.useCount * 12) + let totalBoost = recencyBoost + countBoost + + return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) + } + + private func beginRenameWorkspaceFlow() { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + let target = CommandPaletteRenameTarget( + kind: .workspace(workspaceId: workspace.id), + currentName: workspaceDisplayName(workspace) + ) + startRenameFlow(target) + } + + private func beginRenameTabFlow() { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let panelName = panelDisplayName( + workspace: panelContext.workspace, + panelId: panelContext.panelId, + fallback: panelContext.panel.displayTitle + ) + let target = CommandPaletteRenameTarget( + kind: .tab(workspaceId: panelContext.workspace.id, panelId: panelContext.panelId), + currentName: panelName + ) + startRenameFlow(target) + } + + private func startRenameFlow(_ target: CommandPaletteRenameTarget) { + commandPaletteRenameDraft = target.currentName + commandPaletteMode = .renameInput(target) + resetCommandPaletteRenameFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func continueRenameFlow(target: CommandPaletteRenameTarget) { + guard case .renameInput(let activeTarget) = commandPaletteMode, + activeTarget == target else { return } + applyRenameFlow(target: target, proposedName: commandPaletteRenameDraft) + } + + private func applyRenameFlow(target: CommandPaletteRenameTarget, proposedName: String) { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedName: String? = trimmedName.isEmpty ? nil : trimmedName + + switch target.kind { + case .workspace(let workspaceId): + tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName) + case .tab(let workspaceId, let panelId): + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + NSSound.beep() + return + } + workspace.setPanelCustomTitle(panelId: panelId, title: normalizedName) + } + + dismissCommandPalette() + } + + private func focusFocusedBrowserAddressBar() -> Bool { + guard let panel = tabManager.focusedBrowserPanel else { return false } + _ = panel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } + + private func openFocusedBrowserInDefaultBrowser() -> Bool { + guard let panel = tabManager.focusedBrowserPanel, + let rawURL = panel.preferredURLStringForOmnibar(), + let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return false + } + return NSWorkspace.shared.open(url) + } + + private func openFocusedDirectoryInDefaultApp() -> Bool { + guard let directoryURL = focusedTerminalDirectoryURL() else { return false } + return NSWorkspace.shared.open(directoryURL) + } + + private func focusedTerminalDirectoryURL() -> URL? { + guard let workspace = tabManager.selectedWorkspace else { return nil } + let rawDirectory: String = { + if let focusedPanelId = workspace.focusedPanelId, + let directory = workspace.panelDirectories[focusedPanelId] { + return directory + } + return workspace.currentDirectory + }() + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard FileManager.default.fileExists(atPath: trimmed) else { return nil } + return URL(fileURLWithPath: trimmed, isDirectory: true) + } + #if DEBUG private func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } @@ -1765,6 +4289,572 @@ struct ContentView: View { #endif } +struct CommandPaletteSwitcherSearchMetadata { + let directories: [String] + let branches: [String] + let ports: [Int] + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [] + ) { + self.directories = directories + self.branches = branches + self.ports = ports + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + let normalizedQuery = normalize(query) + guard !normalizedQuery.isEmpty else { return 0 } + let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return 0 } + + let normalizedCandidates = candidates + .map(normalize) + .filter { !$0.isEmpty } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set { + let normalizedQuery = normalize(query) + guard !normalizedQuery.isEmpty else { return [] } + + let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return [] } + + let loweredCandidate = normalize(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in tokens { + if token == loweredCandidate { + matched.formUnion(0.. String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private static func scoreToken(_ token: String, in candidate: String) -> Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length > 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ba6c3261..4dbd0a23 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1450,7 +1450,11 @@ class TabManager: ObservableObject { } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { - guard tabs.contains(where: { $0.id == tabId }) else { return } + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + if let surfaceId, tab.panels[surfaceId] != nil { + // Keep selected-surface intent stable across selectedTabId didSet async restore. + lastFocusedPanelByTab[tabId] = surfaceId + } selectedTabId = tabId NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1469,7 +1473,7 @@ class TabManager: ObservableObject { if let surfaceId { if !suppressFlash { focusSurface(tabId: tabId, surfaceId: surfaceId) - } else if let tab = tabs.first(where: { $0.id == tabId }) { + } else { tab.focusPanel(surfaceId) } } @@ -3055,6 +3059,13 @@ enum ResizeDirection { } extension Notification.Name { + static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") + static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") + static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") + static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") + static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") + static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3f61f26b..3622c596 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -45,6 +45,7 @@ class TerminalController { "browser.focus_webview", "browser.focus", "browser.tab.switch", + "debug.command_palette.toggle", "debug.notification.focus", "debug.app.activate" ] @@ -1279,6 +1280,26 @@ class TerminalController { return v2Result(id: id, self.v2DebugType(params: params)) case "debug.app.activate": return v2Result(id: id, self.v2DebugActivateApp()) + case "debug.command_palette.toggle": + return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params)) + case "debug.command_palette.rename_tab.open": + return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params)) + case "debug.command_palette.visible": + return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params)) + case "debug.command_palette.selection": + return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params)) + case "debug.command_palette.results": + return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params)) + case "debug.command_palette.rename_input.interact": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params)) + case "debug.command_palette.rename_input.delete_backward": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params)) + case "debug.command_palette.rename_input.selection": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params)) + case "debug.command_palette.rename_input.select_all": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) + case "debug.sidebar.visible": + return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params)) case "debug.terminal.read_text": @@ -1475,6 +1496,16 @@ class TerminalController { "debug.shortcut.simulate", "debug.type", "debug.app.activate", + "debug.command_palette.toggle", + "debug.command_palette.rename_tab.open", + "debug.command_palette.visible", + "debug.command_palette.selection", + "debug.command_palette.results", + "debug.command_palette.rename_input.interact", + "debug.command_palette.rename_input.delete_backward", + "debug.command_palette.rename_input.selection", + "debug.command_palette.rename_input.select_all", + "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", "debug.terminal.render_stats", @@ -7564,6 +7595,268 @@ class TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } + private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow) + } + return result + } + + private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + + private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + var selectedIndex = 0 + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex) + ]) + } + + private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let requestedLimit = params["limit"] as? Int + let limit = max(1, min(100, requestedLimit ?? 20)) + + var visible = false + var selectedIndex = 0 + var snapshot = CommandPaletteDebugSnapshot.empty + + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty + } + + let rows = Array(snapshot.results.prefix(limit)).map { row in + [ + "command_id": row.commandId, + "title": row.title, + "shortcut_hint": v2OrNull(row.shortcutHint), + "trailing_label": v2OrNull(row.trailingLabel), + "score": row.score + ] as [String: Any] + } + + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex), + "query": snapshot.query, + "mode": snapshot.mode, + "results": rows + ]) + } + + private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + + var result: V2CallResult = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": false, + "selection_location": 0, + "selection_length": 0, + "text_length": 0 + ]) + + DispatchQueue.main.sync { + guard let window = AppDelegate.shared?.mainWindow(for: windowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + return + } + guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { + return + } + let selectedRange = editor.selectedRange() + let textLength = (editor.string as NSString).length + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": true, + "selection_location": max(0, selectedRange.location), + "selection_length": max(0, selectedRange.length), + "text_length": max(0, textLength) + ]) + } + + return result + } + + private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult { + if let rawEnabled = params["enabled"] { + guard let enabled = rawEnabled as? Bool else { + return .err( + code: "invalid_params", + message: "enabled must be a bool", + data: ["enabled": rawEnabled] + ) + } + DispatchQueue.main.sync { + UserDefaults.standard.set( + enabled, + forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey + ) + } + } + + var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + DispatchQueue.main.sync { + enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + } + + return .ok([ + "enabled": enabled + ]) + } + + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visibility: Bool? + DispatchQueue.main.sync { + visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId) + } + guard let visible = visibility else { + return .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) @@ -8003,17 +8296,24 @@ class TerminalController { var result = "ERROR: Failed to create event" DispatchQueue.main.sync { - // Tests can run while the app is activating (no keyWindow yet). Prefer a visible - // window to keep input simulation deterministic in debug builds. - let targetWindow = NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first + // Prefer the current active-tab-manager window so shortcut simulation stays + // scoped to the intended window even when NSApp.keyWindow is stale. + let targetWindow: NSWindow? = { + if let activeTabManager = self.tabManager, + let windowId = AppDelegate.shared?.windowId(for: activeTabManager), + let window = AppDelegate.shared?.mainWindow(for: windowId) { + return window + } + return NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first + }() if let targetWindow { NSApp.activate(ignoringOtherApps: true) targetWindow.makeKeyAndOrderFront(nil) } - let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 + let windowNumber = targetWindow?.windowNumber ?? 0 guard let keyDownEvent = NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -8706,6 +9006,10 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { + case "esc", "escape": + storedKey = "\u{1b}" + keyCode = UInt16(kVK_Escape) + charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -8726,6 +9030,10 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey + case "backspace", "delete", "del": + storedKey = "\u{7f}" + keyCode = UInt16(kVK_Delete) + charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 091d274e..a5950c24 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -363,6 +363,20 @@ struct cmuxApp: App { // Close tab/workspace CommandGroup(after: .newItem) { + Button("Go to Workspace or Tab…") { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command]) + + Button("Command Palette…") { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command, .shift]) + + Divider() + // Terminal semantics: // Cmd+W closes the focused tab (with confirmation if needed). If this is the last // tab in the last workspace, it closes the window. @@ -422,7 +436,9 @@ struct cmuxApp: App { // Tab navigation CommandGroup(after: .toolbar) { splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) { - sidebarState.toggle() + if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { + sidebarState.toggle() + } } Divider() @@ -2533,6 +2549,18 @@ enum QuitWarningSettings { } } +enum CommandPaletteRenameSelectionSettings { + static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" + static let defaultSelectAllOnFocus = true + + static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: selectAllOnFocusKey) == nil { + return defaultSelectAllOnFocus + } + return defaults.bool(forKey: selectAllOnFocusKey) + } +} + enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" static let defaultHooksEnabled = true @@ -2565,6 +2593,8 @@ struct SettingsView: View { @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @@ -2761,6 +2791,19 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Rename Selects Existing Name", + subtitle: commandPaletteRenameSelectAllOnFocus + ? "Command Palette rename starts with all text selected." + : "Command Palette rename keeps the caret at the end." + ) { + Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Sidebar Branch Layout", subtitle: sidebarBranchVerticalLayout @@ -3310,6 +3353,7 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 05af20eb..a9a8eba6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1125,6 +1125,267 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } +final class CommandPaletteKeyboardNavigationTests: XCTestCase { + func testArrowKeysMoveSelectionWithoutModifiers() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 125 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 126 + ), + -1 + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.shift], + chars: "", + keyCode: 125 + ) + ) + } + + func testControlLetterNavigationSupportsPrintableAndControlChars() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "n", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0e}", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "p", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{10}", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "j", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0a}", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "k", + keyCode: 40 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0b}", + keyCode: 40 + ), + -1 + ) + } + + func testIgnoresUnsupportedModifiersAndKeys() { + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control, .shift], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "x", + keyCode: 7 + ) + ) + } +} + +final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { + private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" + + private func makeDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + func testDefaultsToSelectAllWhenUnset() { + let defaults = makeDefaults() + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsFalseWhenStoredFalse() { + let defaults = makeDefaults() + defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsTrueWhenStoredTrue() { + let defaults = makeDefaults() + defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } +} + +final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { + func testFirstEntryAlwaysPinsToTopWhenScrollable() { + let anchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 0, + previousIndex: 1, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .top) + } + + func testLastEntryAlwaysPinsToBottomWhenScrollable() { + let anchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 19, + previousIndex: 18, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .bottom) + } + + func testFullyVisibleMiddleEntryDoesNotScroll() { + let anchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 6, + previousIndex: 5, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(anchor) + } + + func testOutOfViewMiddleEntryUsesDirectionForAnchor() { + let downAnchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 9, + previousIndex: 8, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(downAnchor, .bottom) + + let upAnchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 8, + previousIndex: 9, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(upAnchor, .top) + } +} + +final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase { + func testTopEdgeReturnsTopWhenNotPinned() { + let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 0, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .top) + } + + func testBottomEdgeReturnsBottomWhenNotPinned() { + let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 19, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .bottom) + } + + func testPinnedTopAndBottomReturnNil() { + let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 0, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(topAnchor) + + let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 19, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(bottomAnchor) + } + + func testMiddleSelectionNeverForcesCorrection() { + let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 8, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(anchor) + } +} + final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintRequiresCommandOnlyModifier() { XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command])) diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index d5464d73..1610dc34 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -1,4 +1,5 @@ import XCTest +import AppKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -106,3 +107,333 @@ final class WorkspaceManualUnreadTests: XCTestCase { ) } } + +final class CommandPaletteFuzzyMatcherTests: XCTestCase { + func testExactMatchScoresHigherThanPrefixAndContains() { + let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab") + let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now") + let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow") + + XCTAssertNotNil(exact) + XCTAssertNotNil(prefix) + XCTAssertNotNil(contains) + XCTAssertGreaterThan(exact ?? 0, prefix ?? 0) + XCTAssertGreaterThan(prefix ?? 0, contains ?? 0) + } + + func testInitialismMatchReturnsScore() { + let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testLongTokenLooseSubsequenceDoesNotMatch() { + let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide") + XCTAssertNil(score) + } + + func testStitchedWordPrefixMatchesRetabForRenameTab() { + let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testRetabPrefersRenameTabOverDistantTabWord() { + let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab") + + XCTAssertNotNil(renameTabScore) + XCTAssertNotNil(reopenTabScore) + XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0) + } + + func testRenameScoresHigherThanUnrelatedCommand() { + let renameScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"] + ) + let unrelatedScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: [ + "Open Current Directory in IDE", + "Terminal • Terminal 1", + "terminal", + "directory", + "open", + "ide", + "code", + "default app" + ] + ) + + XCTAssertNotNil(renameScore) + XCTAssertNotNil(unrelatedScore) + XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0) + } + + func testTokenMatchingRequiresAllTokens() { + let match = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Workspace", "Workspace settings"] + ) + let miss = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Tab", "Tab settings"] + ) + + XCTAssertNotNil(match) + XCTAssertNil(miss) + } + + func testEmptyQueryReturnsZeroScore() { + let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything") + XCTAssertEqual(score, 0) + } + + func testMatchCharacterIndicesForContainsMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "workspace", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(4)) + XCTAssertTrue(indices.contains(12)) + XCTAssertFalse(indices.contains(0)) + } + + func testMatchCharacterIndicesForSubsequenceMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "nws", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(2)) + XCTAssertTrue(indices.contains(8)) + } + + func testMatchCharacterIndicesForStitchedWordPrefixMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "retab", + candidate: "Rename Tab…" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(1)) + XCTAssertTrue(indices.contains(7)) + XCTAssertTrue(indices.contains(8)) + XCTAssertTrue(indices.contains(9)) + } +} + +final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase { + func testKeywordsIncludeDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000, 9222] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace", "switch"], + metadata: metadata + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertTrue(keywords.contains(":9222")) + } + + func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"], + branches: ["fix/switcher-metadata"], + ports: [4317] + ) + + let candidates = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata + ) + + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates)) + } + + func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertFalse(keywords.contains("feat-cmd-palette")) + XCTAssertFalse(keywords.contains("cmd-palette-indexing")) + } + + func testSurfaceDetailOutranksWorkspaceDetailForPathToken() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/worktrees/cmux"], + branches: ["feature/cmd-palette"], + ports: [] + ) + + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["surface"], + metadata: metadata, + detail: .surface + ) + + let workspaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords) + ) + let surfaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords) + ) + + XCTAssertGreaterThan( + surfaceScore, + workspaceScore, + "Surface rows should rank ahead of workspace rows for directory-token matches." + ) + } +} + +@MainActor +final class CommandPaletteRequestRoutingTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testRequestedWindowTargetsOnlyMatchingObservedWindow() { + let windowA = makeWindow() + let windowB = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowA, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowB, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + } + + func testNilRequestedWindowFallsBackToKeyWindow() { + let key = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: key, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + } + + func testNilRequestedAndKeyFallsBackToMainWindow() { + let main = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: main, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + } + + func testNoObservedWindowNeverHandlesRequest() { + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: nil, + requestedWindow: makeWindow(), + keyWindow: makeWindow(), + mainWindow: makeWindow() + ) + ) + } +} + +final class CommandPaletteBackNavigationTests: XCTestCase { + func testBackspaceOnEmptyRenameInputReturnsToCommandList() { + XCTAssertTrue( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [] + ) + ) + } + + func testBackspaceWithRenameTextDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "Terminal 1", + modifiers: [] + ) + ) + } + + func testModifiedBackspaceDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.control] + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.command] + ) + ) + } +} diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index cf94aae2..18af2284 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -918,6 +918,27 @@ class cmux: def activate_app(self) -> None: self._call("debug.app.activate") + def open_command_palette_rename_tab_input(self, window_id: Optional[str] = None) -> None: + params: Dict[str, Any] = {} + if window_id is not None: + params["window_id"] = str(window_id) + self._call("debug.command_palette.rename_tab.open", params) + + def command_palette_results(self, window_id: str, limit: int = 20) -> dict: + res = self._call( + "debug.command_palette.results", + {"window_id": str(window_id), "limit": int(limit)}, + ) or {} + return dict(res) + + def command_palette_rename_select_all(self) -> bool: + res = self._call("debug.command_palette.rename_input.select_all") or {} + return bool(res.get("enabled")) + + def set_command_palette_rename_select_all(self, enabled: bool) -> bool: + res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {} + return bool(res.get("enabled")) + def is_terminal_focused(self, panel: Union[str, int]) -> bool: sid = self._resolve_surface_id(panel) res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {} diff --git a/tests_v2/test_command_palette_backspace_go_back.py b/tests_v2/test_command_palette_backspace_go_back.py new file mode 100644 index 00000000..7b152daa --- /dev/null +++ b/tests_v2/test_command_palette_backspace_go_back.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Regression test: backspace on empty rename input returns to command list. + +Coverage: +- First backspace clears selected rename text. +- Second backspace on empty rename input navigates back to command list mode. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client, window_id): + return client.command_palette_results(window_id, limit=20) + + +def _rename_selection(client, window_id): + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _int_or(value, default): + try: + return int(value) + except (TypeError, ValueError): + return int(default) + + +def _open_rename_input(client, window_id): + client.activate_app() + client.focus_window(window_id) + time.sleep(0.1) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close before setup", + ) + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "rename_input", + message="command palette did not enter rename input mode", + ) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + window_id = client.current_window() + + original_select_all = client.command_palette_rename_select_all() + + try: + client.set_command_palette_rename_select_all(True) + _open_rename_input(client, window_id) + + _wait_until( + lambda: bool(_rename_selection(client, window_id).get("focused")), + message="rename input did not focus", + ) + + selection = _rename_selection(client, window_id) + text_length = _int_or(selection.get("text_length"), 0) + selection_location = _int_or(selection.get("selection_location"), -1) + selection_length = _int_or(selection.get("selection_length"), -1) + if not ( + text_length > 0 + and selection_location in (-1, 0) + and selection_length == text_length + ): + raise cmuxError( + "rename input was not select-all on open: " + f"text_length={text_length} selection=({selection_location}, {selection_length})" + ) + + client._call( + "debug.command_palette.rename_input.delete_backward", + {"window_id": window_id}, + ) + + first_backspace_cleared = False + last_selection = {} + for _ in range(40): + last_selection = _rename_selection(client, window_id) + if _int_or(last_selection.get("text_length"), -1) == 0: + first_backspace_cleared = True + break + time.sleep(0.05) + if not first_backspace_cleared: + raise cmuxError( + "first backspace did not clear rename input: " + f"selection={last_selection} results={_palette_results(client, window_id)}" + ) + after_first = _palette_results(client, window_id) + if str(after_first.get("mode") or "") != "rename_input": + raise cmuxError(f"palette exited rename mode too early after first backspace: {after_first}") + + client._call( + "debug.command_palette.rename_input.delete_backward", + {"window_id": window_id}, + ) + + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="second backspace on empty input did not return to commands mode", + ) + + if not _palette_visible(client, window_id): + raise cmuxError("palette closed unexpectedly instead of navigating back to command list") + + finally: + try: + client.set_command_palette_rename_select_all(original_select_all) + except Exception: + pass + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close during cleanup", + ) + + print("PASS: backspace on empty rename input navigates back to command list") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_focus.py b/tests_v2/test_command_palette_focus.py new file mode 100644 index 00000000..859de7b8 --- /dev/null +++ b/tests_v2/test_command_palette_focus.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Regression test: opening the command palette must move focus away from terminal. + +Why: if terminal remains first responder under the palette, typing goes into the shell +instead of the palette search field. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _focused_surface_id(client: cmux) -> str: + surfaces = client.list_surfaces() + for _, sid, focused in surfaces: + if focused: + return sid + raise cmuxError(f"No focused surface in list_surfaces: {surfaces}") + + +def _palette_visible(client: cmux, window_id: str) -> bool: + res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(res.get("visible")) + + +def _wait_until(predicate, timeout_s: float = 3.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def main() -> int: + token = "CMUX_PALETTE_FOCUS_PROBE_9412" + restore_token = "CMUX_PALETTE_RESTORE_PROBE_7731" + + with cmux(SOCKET_PATH) as client: + client.new_workspace() + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + panel_id = _focused_surface_id(client) + _wait_until( + lambda: client.is_terminal_focused(panel_id), + timeout_s=5.0, + message=f"terminal never became focused for panel {panel_id}", + ) + + pre_text = client.read_terminal_text(panel_id) + + # Open palette via debug method and assert terminal focus drops. + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id), + timeout_s=3.0, + message="command palette did not open", + ) + + # Typing now should target palette input, not the terminal. + client.simulate_type(token) + time.sleep(0.15) + post_text = client.read_terminal_text(panel_id) + + if token in post_text and token not in pre_text: + raise cmuxError("typed probe text leaked into terminal while palette is open") + + # Close palette and ensure focus returns to previously-focused terminal. + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + timeout_s=3.0, + message="command palette did not close", + ) + + client.simulate_type(restore_token) + time.sleep(0.15) + restore_text = client.read_terminal_text(panel_id) + if restore_token not in restore_text: + raise cmuxError("terminal did not receive typing after closing command palette") + + print("PASS: command palette steals and restores terminal focus") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_focus_lock_workspace_spawn.py b/tests_v2/test_command_palette_focus_lock_workspace_spawn.py new file mode 100644 index 00000000..d859b912 --- /dev/null +++ b/tests_v2/test_command_palette_focus_lock_workspace_spawn.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette focus must remain stable while a new workspace shell spawns. + +Why: when a terminal steals first responder during workspace bootstrap, the command-palette +search field can re-focus with full selection, so the next keystroke replaces the whole query. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _palette_input_selection(client: cmux, window_id: str) -> dict: + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _close_palette_if_open(client: cmux, window_id: str) -> None: + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close", + ) + + +def _assert_caret_at_end(selection: dict, context: str) -> None: + if not selection.get("focused"): + raise cmuxError(f"{context}: palette input is not focused") + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + if selection_location != text_length or selection_length != 0: + raise cmuxError( + f"{context}: expected caret-at-end, got location={selection_location}, " + f"length={selection_length}, text_length={text_length}" + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + _close_palette_if_open(client, window_id) + workspace_count_before = len(client.list_workspaces(window_id=window_id)) + + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+shift+p did not open command palette", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="palette did not open in commands mode", + ) + + selection = _palette_input_selection(client, window_id) + _assert_caret_at_end(selection, "initial state") + + client.new_workspace(window_id=window_id) + _wait_until( + lambda: len(client.list_workspaces(window_id=window_id)) >= workspace_count_before + 1, + message="workspace.create did not add a new workspace", + ) + + # Sample across shell bootstrap; focus and caret should stay stable. + sample_deadline = time.time() + 2.0 + while time.time() < sample_deadline: + selection = _palette_input_selection(client, window_id) + _assert_caret_at_end(selection, "after workspace spawn") + time.sleep(0.01) + + client.simulate_type("focuslock") + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="typing after workspace spawn switched palette out of commands mode", + ) + _wait_until( + lambda: "focuslock" in str(_palette_results(client, window_id).get("query") or "").lower(), + message="typing after workspace spawn did not append into command query", + ) + + print("PASS: command palette keeps focus/caret during workspace shell spawn") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_fuzzy_ranking.py b/tests_v2/test_command_palette_fuzzy_ranking.py new file mode 100644 index 00000000..8d6e30b2 --- /dev/null +++ b/tests_v2/test_command_palette_fuzzy_ranking.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette fuzzy ranking for rename commands. + +Validates: +- Typing `rename` is captured by the palette query. +- The top-ranked command is a rename command. +- Pressing Enter opens rename input (instead of running an unrelated command). +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +RENAME_COMMAND_IDS = {"palette.renameTab", "palette.renameWorkspace"} + + +def _wait_until(predicate, timeout_s=5.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _rename_input_selection(client: cmux, window_id: str) -> dict: + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _palette_results(client: cmux, window_id: str, limit: int = 10) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + + # Force command mode query regardless transient field-editor selection state. + time.sleep(0.2) + client.simulate_shortcut("cmd+a") + client.simulate_type(">rename") + _wait_until( + lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not update to 'rename'", + ) + + payload = _palette_results(client, window_id, limit=12) + rows = payload.get("results") or [] + if not rows: + raise cmuxError(f"palette returned no results for rename query: {payload}") + + top = rows[0] or {} + top_id = str(top.get("command_id") or "") + top_title = str(top.get("title") or "") + if top_id not in RENAME_COMMAND_IDS: + titles = [str(row.get("title") or "") for row in rows] + raise cmuxError( + f"unexpected top result for 'rename': id={top_id!r} title={top_title!r} results={titles}" + ) + + client.simulate_shortcut("cmd+a") + client.simulate_type(">retab") + _wait_until( + lambda: "retab" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not update to 'retab'", + ) + + retab_payload = _palette_results(client, window_id, limit=12) + retab_rows = retab_payload.get("results") or [] + if not retab_rows: + raise cmuxError(f"palette returned no results for retab query: {retab_payload}") + top_retabs = [str(row.get("command_id") or "") for row in retab_rows[:3]] + if "palette.renameTab" not in top_retabs: + raise cmuxError( + f"'retab' did not rank Rename Tab near top: top3={top_retabs} rows={retab_rows}" + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: _palette_visible(client, window_id) + and bool(_rename_input_selection(client, window_id).get("focused")), + message="Enter did not open rename input for top rename result", + ) + + _set_palette_visible(client, window_id, False) + + print("PASS: command palette fuzzy ranking prioritizes rename commands") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_modes.py b/tests_v2/test_command_palette_modes.py new file mode 100644 index 00000000..482e1c45 --- /dev/null +++ b/tests_v2/test_command_palette_modes.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Regression test: VSCode-like command palette modes. + +Validates: +- Cmd+Shift+P opens commands mode (leading '>' semantics). +- Cmd+P opens workspace/tab switcher mode. +- Repeating Cmd+Shift+P or Cmd+P toggles visibility (open/close). +- Switcher search can jump to another workspace by pressing Enter. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _palette_input_selection(client: cmux, window_id: str) -> dict: + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _wait_for_palette_input_caret_at_end( + client: cmux, + window_id: str, + expected_text_length: int, + message: str, + timeout_s: float = 1.2, +) -> None: + def _matches() -> bool: + selection = _palette_input_selection(client, window_id) + if not selection.get("focused"): + return False + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + return ( + text_length == expected_text_length + and selection_location == expected_text_length + and selection_length == 0 + ) + + _wait_until(_matches, timeout_s=timeout_s, message=message) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + timeout_s=3.0, + message=f"palette visibility did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + ws_a = client.new_workspace(window_id=window_id) + client.select_workspace(ws_a) + client.rename_workspace("alpha-workspace", workspace=ws_a) + + ws_b = client.new_workspace(window_id=window_id) + client.select_workspace(ws_b) + client.rename_workspace("bravo-workspace", workspace=ws_b) + + client.select_workspace(ws_a) + _wait_until( + lambda: client.current_workspace() == ws_a, + message="failed to select workspace alpha before switcher jump", + ) + + _set_palette_visible(client, window_id, False) + + # Cmd+P: switcher mode. + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open command palette", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + time.sleep(0.2) + client.simulate_type("bravo") + _wait_until( + lambda: "bravo" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not include bravo", + ) + switched_rows = (_palette_results(client, window_id, limit=12).get("results") or []) + if not switched_rows: + raise cmuxError("switcher returned no rows for workspace query") + top_id = str((switched_rows[0] or {}).get("command_id") or "") + if not top_id.startswith("switcher."): + raise cmuxError(f"expected switcher row on top for cmd+p query, got: {switched_rows[0]}") + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="palette did not close after selecting switcher row", + ) + _wait_until( + lambda: client.current_workspace() == ws_b, + message="Enter on switcher result did not move to target workspace", + ) + + # Cmd+Shift+P: commands mode. + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+shift+p did not open command palette", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="cmd+shift+p did not open commands mode", + ) + _wait_for_palette_input_caret_at_end( + client, + window_id, + expected_text_length=1, + message="cmd+shift+p should prefill '>' with caret at end (not selected)", + ) + + command_rows = (_palette_results(client, window_id, limit=8).get("results") or []) + if not command_rows: + raise cmuxError("commands mode returned no rows") + top_command_id = str((command_rows[0] or {}).get("command_id") or "") + if not top_command_id.startswith("palette."): + raise cmuxError(f"expected command row in commands mode, got: {command_rows[0]}") + + # Repeating either shortcut should toggle visibility. + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="second cmd+shift+p did not close the command palette", + ) + + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id) + and str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not reopen switcher mode after toggle-close", + ) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="second cmd+p did not close the command palette", + ) + + print("PASS: command palette cmd+p/cmd+shift+p open correct modes and toggle on repeat") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_navigation_keys.py b/tests_v2/test_command_palette_navigation_keys.py new file mode 100644 index 00000000..6a3d4b2a --- /dev/null +++ b/tests_v2/test_command_palette_navigation_keys.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette list navigation keys. + +Validates: +- Down: ArrowDown, Ctrl+N, Ctrl+J +- Up: ArrowUp, Ctrl+P, Ctrl+K +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until( + predicate, + timeout_s: float = 4.0, + interval_s: float = 0.05, + message: str = "timeout", +) -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(res.get("visible")) + + +def _palette_selected_index(client: cmux, window_id: str) -> int: + res = client._call("debug.command_palette.selection", {"window_id": window_id}) or {} + return int(res.get("selected_index") or 0) + + +def _has_focused_surface(client: cmux) -> bool: + try: + return any(bool(row[2]) for row in client.list_surfaces()) + except Exception: + return False + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_palette_with_query(client: cmux, window_id: str, query: str) -> None: + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + client.simulate_type(query) + _wait_until( + lambda: _palette_selected_index(client, window_id) == 0, + message="palette selected index did not reset to zero", + ) + + +def _assert_move(client: cmux, window_id: str, combo: str, start_index: int, expected_index: int) -> None: + _open_palette_with_query(client, window_id, "new") + for _ in range(start_index): + client.simulate_shortcut("down") + _wait_until( + lambda: _palette_selected_index(client, window_id) == start_index, + message=f"failed to seed start index {start_index}", + ) + + client.simulate_shortcut(combo) + _wait_until( + lambda: _palette_visible(client, window_id) + and _palette_selected_index(client, window_id) == expected_index, + message=f"{combo} did not move selection from {start_index} to {expected_index}", + ) + + +def _assert_can_navigate_past_ten_results(client: cmux, window_id: str) -> None: + _open_palette_with_query(client, window_id, "") + + for _ in range(12): + client.simulate_shortcut("down") + + _wait_until( + lambda: _palette_visible(client, window_id) + and _palette_selected_index(client, window_id) >= 10, + message="selection did not move past index 9 (results may be capped)", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + client.new_workspace() + time.sleep(0.2) + + window_id = client.current_window() + # Isolate this test to one window so stale palettes in other windows + # cannot steal navigation notifications. + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + _wait_until( + lambda: _has_focused_surface(client), + timeout_s=5.0, + message="no focused surface available for command palette context", + ) + + for combo in ("down", "ctrl+n", "ctrl+j"): + _assert_move(client, window_id, combo, start_index=0, expected_index=1) + + for combo in ("up", "ctrl+p", "ctrl+k"): + _assert_move(client, window_id, combo, start_index=1, expected_index=0) + + _assert_can_navigate_past_ten_results(client, window_id) + + _set_palette_visible(client, window_id, False) + + print("PASS: command palette navigation keys and uncapped result navigation") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_rename_enter.py b/tests_v2/test_command_palette_rename_enter.py new file mode 100644 index 00000000..749e0ac0 --- /dev/null +++ b/tests_v2/test_command_palette_rename_enter.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette rename flow responds to Enter. + +Coverage: +- Enter in rename input applies the new tab name and closes the palette. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _rename_input_selection(client, window_id): + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _focused_pane_id(client): + panes = client.list_panes() + focused = [row for row in panes if bool(row[3])] + if not focused: + raise cmuxError(f"no focused pane: {panes}") + return str(focused[0][1]) + + +def _selected_surface_title(client, pane_id): + rows = client.list_pane_surfaces(pane_id) + selected = [row for row in rows if bool(row[3])] + if not selected: + raise cmuxError(f"no selected surface in pane {pane_id}: {rows}") + return str(selected[0][2]) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + pane_id = _focused_pane_id(client) + rename_to = f"rename-enter-{int(time.time())}" + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette did not open", + ) + _wait_until( + lambda: bool(_rename_input_selection(client, window_id).get("focused")), + message="rename input did not focus", + ) + + client.simulate_type(rename_to) + time.sleep(0.1) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="Enter did not apply rename and close palette", + ) + + new_title = _selected_surface_title(client, pane_id) + if new_title != rename_to: + raise cmuxError(f"rename not applied: expected '{rename_to}', got '{new_title}'") + + print("PASS: command-palette rename flow accepts Enter in input") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_rename_select_all.py b/tests_v2/test_command_palette_rename_select_all.py new file mode 100644 index 00000000..0b05ab4a --- /dev/null +++ b/tests_v2/test_command_palette_rename_select_all.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette rename input keeps select-all on interaction. + +Coverage: +- With select-all setting enabled, rename input selects all existing text + immediately and stays selected after interaction. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _rename_input_selection(client, window_id): + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _rename_select_all_setting(client): + payload = client._call("debug.command_palette.rename_input.select_all", {}) or {} + return bool(payload.get("enabled")) + + +def _set_rename_select_all_setting(client, enabled): + payload = client._call( + "debug.command_palette.rename_input.select_all", + {"enabled": bool(enabled)}, + ) or {} + return bool(payload.get("enabled")) + + +def _wait_for_rename_selection( + client, + window_id, + expect_select_all, + message, + timeout_s=0.6, +): + def _matches(): + selection = _rename_input_selection(client, window_id) + if not selection.get("focused"): + return False + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + if expect_select_all: + return text_length > 0 and selection_location == 0 and selection_length == text_length + return selection_location == text_length and selection_length == 0 + + _wait_until(_matches, timeout_s=timeout_s, message=message) + + +def _exercise_rename_selection_setting( + client, + window_id, + expect_select_all, + cycles, + label, +): + for cycle in range(cycles): + _open_rename_tab_input(client, window_id) + _wait_for_rename_selection( + client, + window_id, + expect_select_all=expect_select_all, + timeout_s=0.4, + message=( + f"{label}: rename input not ready with expected selection " + f"on open (cycle {cycle + 1}/{cycles})" + ), + ) + client._call("debug.command_palette.rename_input.interact", {"window_id": window_id}) + _wait_for_rename_selection( + client, + window_id, + expect_select_all=expect_select_all, + timeout_s=0.6, + message=( + f"{label}: rename input selection changed after interaction " + f"(cycle {cycle + 1}/{cycles})" + ), + ) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message=f"{label}: command palette failed to close (cycle {cycle + 1}/{cycles})", + ) + + +def _open_rename_tab_input(client, window_id): + client.activate_app() + client.focus_window(window_id) + time.sleep(0.1) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close before setup", + ) + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open rename-tab input", + ) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + original_select_all = _rename_select_all_setting(client) + + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + client.rename_workspace("SeedName", workspace_id) + time.sleep(0.25) + window_id = client.current_window() + + try: + stress_cycles = 8 + + # ON: immediate select-all and interaction-preserved select-all. + _set_rename_select_all_setting(client, True) + _exercise_rename_selection_setting( + client, + window_id, + expect_select_all=True, + cycles=stress_cycles, + label="select-all enabled", + ) + + # OFF: immediate caret-at-end and interaction-preserved caret-at-end. + _set_rename_select_all_setting(client, False) + _exercise_rename_selection_setting( + client, + window_id, + expect_select_all=False, + cycles=stress_cycles, + label="select-all disabled", + ) + + finally: + try: + _set_rename_select_all_setting(client, original_select_all) + except Exception: + pass + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close during cleanup", + ) + + print("PASS: command-palette rename input obeys select-all setting (on/off)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_search_action_sync.py b/tests_v2/test_command_palette_search_action_sync.py new file mode 100644 index 00000000..533cb7e3 --- /dev/null +++ b/tests_v2/test_command_palette_search_action_sync.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette search updates rows and executed action in sync. + +Why: if query replacement doesn't fully refresh the result list, the top row text +can lag behind the action executed on Enter. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _set_palette_visible(client, window_id, visible): + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"command palette did not become visible={visible}", + ) + + +def _palette_results(client, window_id, limit=10): + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _palette_input_selection(client, window_id): + # Shared field-editor probe used by other command palette regressions. + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + _wait_until( + lambda: bool(_palette_input_selection(client, window_id).get("focused")), + message="palette search input did not focus", + ) + + client.simulate_shortcut("cmd+a") + client.simulate_type(">open") + _wait_until( + lambda: "open" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not become 'open'", + ) + + before = _palette_results(client, window_id, limit=8) + before_rows = before.get("results") or [] + if not before_rows: + raise cmuxError(f"no results for 'open': {before}") + if str(before_rows[0].get("command_id") or "") != "palette.terminalOpenDirectory": + raise cmuxError(f"unexpected top command for 'open': {before_rows[0]}") + + client.simulate_shortcut("cmd+a") + client.simulate_type(">rename") + _wait_until( + lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not become 'rename' after replacement", + ) + after = _palette_results(client, window_id, limit=8) + after_rows = after.get("results") or [] + if not after_rows: + raise cmuxError(f"no results for 'rename' after replacement: {after}") + top_after = str(after_rows[0].get("command_id") or "") + if top_after not in {"palette.renameWorkspace", "palette.renameTab"}: + raise cmuxError(f"top result did not update to rename command after replacement: {after_rows[0]}") + + client.simulate_shortcut("enter") + _wait_until( + lambda: bool(_palette_input_selection(client, window_id).get("focused")), + message="Enter did not trigger renamed top command input", + ) + + _set_palette_visible(client, window_id, False) + + print("PASS: command-palette search replacement keeps row text/action in sync") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_search_typing_stability.py b/tests_v2/test_command_palette_search_typing_stability.py new file mode 100644 index 00000000..09b34722 --- /dev/null +++ b/tests_v2/test_command_palette_search_typing_stability.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette search typing should not reset selection. + +Why: if focus-lock logic repeatedly re-focuses the text field, typing behaves +like Cmd+A is being spammed and each character replaces the previous query. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.04, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_input_selection(client, window_id): + # Uses the shared field-editor probe; works for search and rename modes. + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _wait_for_input_state(client, window_id, expected_text_length, message, timeout_s=0.8): + def _matches(): + selection = _palette_input_selection(client, window_id) + if not selection.get("focused"): + return False + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + return ( + text_length == expected_text_length + and selection_location == expected_text_length + and selection_length == 0 + ) + + _wait_until(_matches, timeout_s=timeout_s, message=message) + + +def _close_palette_if_open(client, window_id): + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close", + ) + + +def _open_palette(client, window_id): + _close_palette_if_open(client, window_id) + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open", + ) + _wait_for_input_state( + client, + window_id, + expected_text_length=0, + message="search input did not focus with empty query", + ) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + + # Keep a single active window for deterministic first-responder behavior. + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + probe = "typingstability" + cycles = 4 + for cycle in range(cycles): + _open_palette(client, window_id) + for idx, ch in enumerate(probe, start=1): + client.simulate_type(ch) + _wait_for_input_state( + client, + window_id, + expected_text_length=idx, + timeout_s=0.7, + message=( + f"search typing did not accumulate at cycle {cycle + 1}/{cycles}, " + f"char {idx}/{len(probe)}" + ), + ) + _close_palette_if_open(client, window_id) + + print("PASS: command-palette search typing accumulates text without select-all churn") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py new file mode 100644 index 00000000..ba427506 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher surface selection across workspaces must focus that surface. + +Why: switching workspaces with an explicit target surface could be overridden by stale +per-workspace remembered focus, leaving the destination workspace selected but the wrong +surface focused. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def _rename_surface(client: cmux, surface_id: str, title: str) -> None: + client._call( + "surface.action", + { + "surface_id": surface_id, + "action": "rename", + "title": title, + }, + ) + + +def _current_surface_id(client: cmux, workspace_id: str) -> str: + payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} + return str(payload.get("surface_id") or "") + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + ws_a = client.new_workspace(window_id=window_id) + client.select_workspace(ws_a) + client.rename_workspace("source-workspace", workspace=ws_a) + + ws_b = client.new_workspace(window_id=window_id) + client.select_workspace(ws_b) + client.rename_workspace("target-workspace", workspace=ws_b) + time.sleep(0.2) + + right_surface_id = client.new_split("right") + time.sleep(0.2) + + payload = client._call("surface.list", {"workspace_id": ws_b}) or {} + rows = payload.get("surfaces") or [] + if len(rows) < 2: + raise cmuxError(f"expected at least two surfaces after split: {payload}") + + left_surface_id = "" + for row in rows: + sid = str(row.get("id") or "") + if sid and sid != right_surface_id: + left_surface_id = sid + break + if not left_surface_id: + raise cmuxError(f"failed to resolve left surface id: {payload}") + + token = f"cmdp-crossws-{int(time.time() * 1000)}" + _rename_surface(client, right_surface_id, token) + time.sleep(0.2) + + client.focus_surface(left_surface_id) + _wait_until( + lambda: _current_surface_id(client, ws_b).lower() == left_surface_id.lower(), + message="failed to prime remembered focus on non-target surface", + ) + + client.select_workspace(ws_a) + _wait_until( + lambda: client.current_workspace() == ws_a, + message="failed to return to source workspace before cmd+p navigation", + ) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not update to target token", + ) + + target_command_id = f"switcher.surface.{ws_b.lower()}.{right_surface_id.lower()}" + _wait_until( + lambda: str(((_palette_results(client, window_id, limit=24).get("results") or [{}])[0] or {}).get("command_id") or "") == target_command_id, + message="target surface row did not become top switcher result", + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="palette did not close after selecting cross-workspace surface row", + ) + _wait_until( + lambda: client.current_workspace() == ws_b, + message="Enter on switcher surface row did not move to target workspace", + ) + _wait_until( + lambda: _current_surface_id(client, ws_b).lower() == right_surface_id.lower(), + message="Enter on cross-workspace switcher surface row did not focus target surface", + ) + + client.close_workspace(ws_b) + client.close_workspace(ws_a) + + print("PASS: cmd+p switcher focuses selected surface after cross-workspace navigation") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_renamed_surface.py b/tests_v2/test_command_palette_switcher_renamed_surface.py new file mode 100644 index 00000000..99b2fce0 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_renamed_surface.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher should search and navigate to renamed surfaces. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def _rename_surface(client: cmux, surface_id: str, title: str) -> None: + client._call( + "surface.action", + { + "surface_id": surface_id, + "action": "rename", + "title": title, + }, + ) + + +def _current_surface_id(client: cmux, workspace_id: str) -> str: + payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} + return str(payload.get("surface_id") or "") + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + right_surface_id = client.new_split("right") + time.sleep(0.2) + + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + rows = payload.get("surfaces") or [] + if len(rows) < 2: + raise cmuxError(f"expected at least two surfaces after split: {payload}") + + left_surface_id = "" + for row in rows: + sid = str(row.get("id") or "") + if sid and sid != right_surface_id: + left_surface_id = sid + break + if not left_surface_id: + raise cmuxError(f"failed to resolve left surface id: {payload}") + + token = f"renamed-surface-{int(time.time() * 1000)}" + _rename_surface(client, right_surface_id, token) + time.sleep(0.2) + + client.focus_surface(left_surface_id) + time.sleep(0.2) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not update to renamed surface token", + ) + + result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) + if not result_rows: + raise cmuxError("switcher returned no rows for renamed surface query") + + top_row = result_rows[0] or {} + top_id = str(top_row.get("command_id") or "") + top_title = str(top_row.get("title") or "") + if not top_id.startswith("switcher.surface."): + raise cmuxError( + f"expected renamed surface row on top, got top={top_id!r} rows={result_rows}" + ) + if top_title != token: + raise cmuxError( + f"expected top surface row title to match renamed title {token!r}, got {top_title!r}" + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="palette did not close after selecting renamed surface row", + ) + + _wait_until( + lambda: _current_surface_id(client, workspace_id).lower() == right_surface_id.lower(), + message="Enter on renamed surface switcher row did not focus target surface", + ) + + client.close_workspace(workspace_id) + + print("PASS: cmd+p switcher searches and navigates renamed surfaces") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_surface_precedence.py b/tests_v2/test_command_palette_switcher_surface_precedence.py new file mode 100644 index 00000000..ec3850f5 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_surface_precedence.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Regression test: switcher should prioritize matching surfaces over workspace rows. + +Why: workspace rows used to index metadata from all surfaces, so a path-token query +could rank the workspace row above the actual surface row (because of stable rank +tie-breaks), making Enter jump to workspace instead of the intended surface. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + client.rename_workspace("workspace-no-token", workspace=workspace_id) + time.sleep(0.2) + + right_surface_id = client.new_split("right") + time.sleep(0.2) + + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + rows = payload.get("surfaces") or [] + if len(rows) < 2: + raise cmuxError(f"expected at least two surfaces after split: {payload}") + + left_surface_id = "" + for row in rows: + sid = str(row.get("id") or "") + if sid and sid != right_surface_id: + left_surface_id = sid + break + if not left_surface_id: + raise cmuxError(f"failed to resolve left surface id: {payload}") + + token = f"cmdp-switcher-target-{int(time.time() * 1000)}" + target_dir = f"/tmp/{token}" + + client.send_surface(left_surface_id, "cd /tmp\n") + client.send_surface( + right_surface_id, + f"mkdir -p {target_dir} && cd {target_dir}\n", + ) + client.focus_surface(left_surface_id) + time.sleep(0.8) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not update to target token", + ) + + def _has_surface_match() -> bool: + result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) + return any(str((row or {}).get("command_id") or "").startswith("switcher.surface.") for row in result_rows) + + _wait_until( + _has_surface_match, + timeout_s=8.0, + message="switcher results never produced a matching surface row for token query", + ) + + result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) + if not result_rows: + raise cmuxError("switcher returned no rows for token query") + + top_id = str((result_rows[0] or {}).get("command_id") or "") + if not top_id.startswith("switcher.surface."): + raise cmuxError(f"expected a surface row on top for token query, got top={top_id!r} rows={result_rows}") + + workspace_matches = [ + str((row or {}).get("command_id") or "") + for row in result_rows + if str((row or {}).get("command_id") or "").startswith("switcher.workspace.") + ] + if workspace_matches: + raise cmuxError( + f"workspace row should not match a non-focused surface path token; workspace matches={workspace_matches} rows={result_rows}" + ) + + _set_palette_visible(client, window_id, False) + client.close_workspace(workspace_id) + + print("PASS: switcher ranks matching surface rows ahead of workspace rows for path-token queries") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_type_labels.py b/tests_v2/test_command_palette_switcher_type_labels.py new file mode 100644 index 00000000..dbbe2fcd --- /dev/null +++ b/tests_v2/test_command_palette_switcher_type_labels.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher rows expose right-side type labels. + +Expected trailing labels: +- switcher.workspace.* => Workspace +- switcher.surface.* => Surface +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + token = f"switchertype{int(time.time() * 1000)}" + client.rename_workspace(token, workspace=workspace_id) + _ = client.new_split("right") + time.sleep(0.3) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id, limit=60).get("query") or "").strip().lower(), + message="switcher query did not update to workspace token", + ) + + rows = (_palette_results(client, window_id, limit=60).get("results") or []) + if not rows: + raise cmuxError("switcher returned no rows for token query") + + workspace_rows = [ + row for row in rows + if str((row or {}).get("command_id") or "").startswith("switcher.workspace.") + ] + surface_rows = [ + row for row in rows + if str((row or {}).get("command_id") or "").startswith("switcher.surface.") + ] + + if not workspace_rows: + raise cmuxError(f"expected workspace rows for switcher query: rows={rows}") + if not surface_rows: + raise cmuxError(f"expected surface rows for switcher query: rows={rows}") + + bad_workspace = [row for row in workspace_rows if str((row or {}).get("trailing_label") or "") != "Workspace"] + if bad_workspace: + raise cmuxError(f"workspace rows missing 'Workspace' trailing label: {bad_workspace}") + + bad_surface = [row for row in surface_rows if str((row or {}).get("trailing_label") or "") != "Surface"] + if bad_surface: + raise cmuxError(f"surface rows missing 'Surface' trailing label: {bad_surface}") + + _set_palette_visible(client, window_id, False) + client.close_workspace(workspace_id) + + print("PASS: cmd+p switcher rows report Workspace/Surface trailing labels") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_window_scope.py b/tests_v2/test_command_palette_window_scope.py new file mode 100644 index 00000000..63236c34 --- /dev/null +++ b/tests_v2/test_command_palette_window_scope.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette should open only in the active window. + +Why: if command-palette toggle is broadcast to all windows, inactive windows can +end up with an open palette that steals focus once they become key. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(res.get("visible")) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + timeout_s=3.0, + message=f"palette in {window_id} did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + w1 = client.current_window() + w2 = client.new_window() + time.sleep(0.25) + + ws1 = client.new_workspace(window_id=w1) + ws2 = client.new_workspace(window_id=w2) + time.sleep(0.25) + _set_palette_visible(client, w1, False) + _set_palette_visible(client, w2, False) + + # Open palette in window1 and verify window2 remains untouched. + client._call("debug.command_palette.toggle", {"window_id": w1}) + _wait_until( + lambda: _palette_visible(client, w1), + timeout_s=3.0, + message="window1 command palette did not open", + ) + if _palette_visible(client, w2): + raise cmuxError("window2 palette became visible when toggling window1") + + # Closing window1 palette should not affect window2. + client._call("debug.command_palette.toggle", {"window_id": w1}) + _wait_until( + lambda: not _palette_visible(client, w1), + timeout_s=3.0, + message="window1 command palette did not close", + ) + + # Mirror the same check in the other direction. + client._call("debug.command_palette.toggle", {"window_id": w2}) + _wait_until( + lambda: _palette_visible(client, w2), + timeout_s=3.0, + message="window2 command palette did not open", + ) + if _palette_visible(client, w1): + raise cmuxError("window1 palette became visible when toggling window2") + client._call("debug.command_palette.toggle", {"window_id": w2}) + _wait_until( + lambda: not _palette_visible(client, w2), + timeout_s=3.0, + message="window2 command palette did not close", + ) + + print("PASS: command palette is scoped to active window") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_shortcut_window_scope.py b/tests_v2/test_shortcut_window_scope.py new file mode 100644 index 00000000..a13750e2 --- /dev/null +++ b/tests_v2/test_shortcut_window_scope.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Regression test: app shortcuts must apply to the focused window only. + +Covers: +- Cmd+B (toggle sidebar) should only affect the active window. +- Cmd+T (new terminal tab/surface) should only affect the active window. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 4.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _sidebar_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.sidebar.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _surface_count(client: cmux, workspace_id: str) -> int: + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + return len(payload.get("surfaces") or []) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_a = client.current_window() + window_b = client.new_window() + time.sleep(0.25) + + workspace_a = client.new_workspace(window_id=window_a) + workspace_b = client.new_workspace(window_id=window_b) + time.sleep(0.25) + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + + a_before = _sidebar_visible(client, window_a) + b_before = _sidebar_visible(client, window_b) + + client.simulate_shortcut("cmd+b") + _wait_until( + lambda: _sidebar_visible(client, window_a) != a_before, + message="Cmd+B did not toggle sidebar in active window A", + ) + a_after = _sidebar_visible(client, window_a) + b_after = _sidebar_visible(client, window_b) + if b_after != b_before: + raise cmuxError("Cmd+B in window A incorrectly toggled sidebar in window B") + + client.focus_window(window_b) + client.activate_app() + time.sleep(0.2) + + client.simulate_shortcut("cmd+b") + _wait_until( + lambda: _sidebar_visible(client, window_b) != b_after, + message="Cmd+B did not toggle sidebar in active window B", + ) + if _sidebar_visible(client, window_a) != a_after: + raise cmuxError("Cmd+B in window B incorrectly toggled sidebar in window A") + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + client.select_workspace(workspace_a) + time.sleep(0.1) + + count_a_before = _surface_count(client, workspace_a) + count_b_before = _surface_count(client, workspace_b) + + client.simulate_shortcut("cmd+t") + _wait_until( + lambda: _surface_count(client, workspace_a) == count_a_before + 1, + message="Cmd+T did not create a new surface in active window A", + ) + + count_b_after = _surface_count(client, workspace_b) + if count_b_after != count_b_before: + raise cmuxError("Cmd+T in window A incorrectly created a surface in window B") + + print("PASS: window-scoped shortcuts stay in the active window (Cmd+B, Cmd+T)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 8d03657c946a4b02cbeb2e99d5ef8b4a284b5207 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 04:08:01 -0800 Subject: [PATCH 033/136] Command palette caret uses white tint (#361) * Set command palette caret tint to white * Add command palette window actions and shortcut sync --- Sources/AppDelegate.swift | 19 +++ Sources/ContentView.swift | 48 ++++++- Sources/KeyboardShortcutSettings.swift | 10 ++ Sources/TerminalController.swift | 55 +++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 24 ++++ tests/test_lint_swiftui_patterns.py | 68 +++++++++- ...test_command_palette_shortcut_hint_sync.py | 119 ++++++++++++++++++ tests_v2/test_lint_swiftui_patterns.py | 68 +++++++++- 8 files changed, 380 insertions(+), 31 deletions(-) create mode 100644 tests_v2/test_command_palette_shortcut_hint_sync.py diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 419276d6..35af3c73 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2315,6 +2315,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + targetWindow.performClose(nil) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameTab)) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + return true + } + // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) if flags == [.command], let manager = tabManager, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8359f265..7817856d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2380,7 +2380,7 @@ struct ContentView: View { TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) - .tint(.blue) + .tint(.white) .focused($isCommandPaletteSearchFocused) .onSubmit { runSelectedCommandPaletteResult(visibleResults: visibleResults) @@ -2582,7 +2582,7 @@ struct ContentView: View { TextField(target.placeholder, text: $commandPaletteRenameDraft) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) - .tint(.blue) + .tint(.white) .focused($isCommandPaletteRenameFocused) .backport.onKeyPress(.delete) { modifiers in handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) @@ -2941,7 +2941,7 @@ struct ContentView: View { rank: nextRank, title: contribution.title(context), subtitle: contribution.subtitle(context), - shortcutHint: commandPaletteShortcutHint(for: contribution), + shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), keywords: contribution.keywords, dismissOnRun: contribution.dismissOnRun, action: action @@ -2953,7 +2953,15 @@ struct ContentView: View { return commands } - private func commandPaletteShortcutHint(for contribution: CommandPaletteCommandContribution) -> String? { + private func commandPaletteShortcutHint( + for contribution: CommandPaletteCommandContribution, + context: CommandPaletteContextSnapshot + ) -> String? { + // Preserve browser reload semantics for Cmd+R when a browser tab is focused. + if contribution.commandId == "palette.renameTab", + context.bool(CommandPaletteContextKeys.panelIsBrowser) { + return nil + } if let action = commandPaletteShortcutAction(for: contribution.commandId) { return KeyboardShortcutSettings.shortcut(for: action).displayString } @@ -2967,16 +2975,22 @@ struct ContentView: View { switch commandId { case "palette.newWorkspace": return .newTab + case "palette.newWindow": + return .newWindow case "palette.newTerminalTab": return .newSurface case "palette.newBrowserTab": return .openBrowser + case "palette.closeWindow": + return .closeWindow case "palette.toggleSidebar": return .toggleSidebar case "palette.showNotifications": return .showNotifications case "palette.jumpUnread": return .jumpToUnread + case "palette.renameTab": + return .renameTab case "palette.renameWorkspace": return .renameWorkspace case "palette.nextWorkspace": @@ -3108,6 +3122,14 @@ struct ContentView: View { keywords: ["create", "new", "workspace"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWindow", + title: constant("New Window"), + subtitle: constant("Window"), + keywords: ["create", "new", "window"] + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.newTerminalTab", @@ -3144,6 +3166,14 @@ struct ContentView: View { keywords: ["close", "workspace"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWindow", + title: constant("Close Window"), + subtitle: constant("Window"), + keywords: ["close", "window"] + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.reopenClosedBrowserTab", @@ -3543,6 +3573,9 @@ struct ContentView: View { registry.register(commandId: "palette.newWorkspace") { tabManager.addWorkspace() } + registry.register(commandId: "palette.newWindow") { + AppDelegate.shared?.openNewMainWindow(nil) + } registry.register(commandId: "palette.newTerminalTab") { tabManager.newSurface() } @@ -3555,6 +3588,13 @@ struct ContentView: View { registry.register(commandId: "palette.closeWorkspace") { tabManager.closeCurrentWorkspaceWithConfirmation() } + registry.register(commandId: "palette.closeWindow") { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return + } + window.performClose(nil) + } registry.register(commandId: "palette.reopenClosedBrowserTab") { _ = tabManager.reopenMostRecentlyClosedBrowserPanel() } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 4316a41e..61d7b799 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -8,6 +8,7 @@ enum KeyboardShortcutSettings { case toggleSidebar case newTab case newWindow + case closeWindow case showNotifications case jumpToUnread case triggerFlash @@ -17,6 +18,7 @@ enum KeyboardShortcutSettings { case prevSurface case nextSidebarTab case prevSidebarTab + case renameTab case renameWorkspace case closeWorkspace case newSurface @@ -43,6 +45,7 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "Toggle Sidebar" case .newTab: return "New Workspace" case .newWindow: return "New Window" + case .closeWindow: return "Close Window" case .showNotifications: return "Show Notifications" case .jumpToUnread: return "Jump to Latest Unread" case .triggerFlash: return "Flash Focused Panel" @@ -50,6 +53,7 @@ enum KeyboardShortcutSettings { case .prevSurface: return "Previous Surface" case .nextSidebarTab: return "Next Workspace" case .prevSidebarTab: return "Previous Workspace" + case .renameTab: return "Rename Tab" case .renameWorkspace: return "Rename Workspace" case .closeWorkspace: return "Close Workspace" case .newSurface: return "New Surface" @@ -72,11 +76,13 @@ enum KeyboardShortcutSettings { case .toggleSidebar: return "shortcut.toggleSidebar" case .newTab: return "shortcut.newTab" case .newWindow: return "shortcut.newWindow" + case .closeWindow: return "shortcut.closeWindow" case .showNotifications: return "shortcut.showNotifications" case .jumpToUnread: return "shortcut.jumpToUnread" case .triggerFlash: return "shortcut.triggerFlash" case .nextSidebarTab: return "shortcut.nextSidebarTab" case .prevSidebarTab: return "shortcut.prevSidebarTab" + case .renameTab: return "shortcut.renameTab" case .renameWorkspace: return "shortcut.renameWorkspace" case .closeWorkspace: return "shortcut.closeWorkspace" case .focusLeft: return "shortcut.focusLeft" @@ -104,6 +110,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) + case .closeWindow: + return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) case .showNotifications: return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) case .jumpToUnread: @@ -114,6 +122,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) case .prevSidebarTab: return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) + case .renameTab: + return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) case .renameWorkspace: return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) case .closeWorkspace: diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3622c596..9b392625 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8230,6 +8230,37 @@ class TerminalController { } #if DEBUG + private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { + let snakeCase = action.rawValue.replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1_$2", + options: .regularExpression + ) + return snakeCase.lowercased() + } + + private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { + let normalized = rawName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "-", with: "_") + + for action in KeyboardShortcutSettings.Action.allCases { + let snakeCaseName = debugShortcutName(for: action) + if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { + return action + } + } + return nil + } + + private func debugShortcutSupportedNames() -> String { + KeyboardShortcutSettings.Action.allCases + .map(debugShortcutName(for:)) + .sorted() + .joined(separator: ", ") + } + private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -8237,29 +8268,15 @@ class TerminalController { return "ERROR: Usage: set_shortcut " } - let name = parts[0].lowercased() + let name = parts[0] let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - let defaultsKey: String? - switch name { - case "focus_left", "focusleft": - defaultsKey = KeyboardShortcutSettings.focusLeftKey - case "focus_right", "focusright": - defaultsKey = KeyboardShortcutSettings.focusRightKey - case "focus_up", "focusup": - defaultsKey = KeyboardShortcutSettings.focusUpKey - case "focus_down", "focusdown": - defaultsKey = KeyboardShortcutSettings.focusDownKey - default: - defaultsKey = nil - } - - guard let defaultsKey else { - return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" + guard let action = debugShortcutAction(named: name) else { + return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: defaultsKey) + UserDefaults.standard.removeObject(forKey: action.defaultsKey) return "OK" } @@ -8277,7 +8294,7 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: defaultsKey) + UserDefaults.standard.set(data, forKey: action.defaultsKey) return "OK" } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a9a8eba6..8341c5f1 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -555,6 +555,30 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { } final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { + func testRenameTabShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.label, "Rename Tab") + XCTAssertEqual(KeyboardShortcutSettings.Action.renameTab.defaultsKey, "shortcut.renameTab") + + let shortcut = KeyboardShortcutSettings.Action.renameTab.defaultShortcut + XCTAssertEqual(shortcut.key, "r") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertFalse(shortcut.control) + } + + func testCloseWindowShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.label, "Close Window") + XCTAssertEqual(KeyboardShortcutSettings.Action.closeWindow.defaultsKey, "shortcut.closeWindow") + + let shortcut = KeyboardShortcutSettings.Action.closeWindow.defaultShortcut + XCTAssertEqual(shortcut.key, "w") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.option) + XCTAssertTrue(shortcut.control) + } + func testRenameWorkspaceShortcutDefaultsAndMetadata() { XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.label, "Rename Workspace") XCTAssertEqual(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey, "shortcut.renameWorkspace") diff --git a/tests/test_lint_swiftui_patterns.py b/tests/test_lint_swiftui_patterns.py index f5d82c14..685480eb 100644 --- a/tests/test_lint_swiftui_patterns.py +++ b/tests/test_lint_swiftui_patterns.py @@ -9,6 +9,7 @@ This test checks for: from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s return violations +def check_command_palette_caret_tint(repo_root: Path) -> List[str]: + """Ensure command palette text inputs keep a white caret tint.""" + content_view = repo_root / "Sources" / "ContentView.swift" + if not content_view.exists(): + return [f"Missing expected file: {content_view}"] + + try: + content = content_view.read_text() + except Exception as e: + return [f"Could not read {content_view}: {e}"] + + checks = [ + ( + "search input", + r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P.*?)" + r"\.focused\(\$isCommandPaletteSearchFocused\)", + ), + ( + "rename input", + r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P.*?)" + r"\.focused\(\$isCommandPaletteRenameFocused\)", + ), + ] + + violations: List[str] = [] + for label, pattern in checks: + match = re.search(pattern, content, flags=re.DOTALL) + if not match: + violations.append( + f"Could not locate command palette {label} TextField block in Sources/ContentView.swift" + ) + continue + + body = match.group("body") + if ".tint(.white)" not in body: + violations.append( + f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift" + ) + + return violations + + def main(): """Run the lint checks.""" repo_root = get_repo_root() @@ -102,15 +145,18 @@ def main(): print(f"Checking {len(swift_files)} Swift files for performance issues...") # Check for auto-updating Text styles - violations = check_autoupdating_text_styles(swift_files) + style_violations = check_autoupdating_text_styles(swift_files) + tint_violations = check_command_palette_caret_tint(repo_root) + has_failures = False - if violations: + if style_violations: + has_failures = True print("\n❌ LINT FAILURES: Auto-updating Text styles found") print("=" * 60) print("These patterns cause continuous SwiftUI view updates and high CPU usage:") print() - for file_path, line_num, line in violations: + for file_path, line_num, line in style_violations: rel_path = file_path.relative_to(repo_root) print(f" {rel_path}:{line_num}") print(f" {line}") @@ -120,9 +166,23 @@ def main(): print(" Instead of: Text(date, style: .time)") print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") print() + + if tint_violations: + has_failures = True + print("\n❌ LINT FAILURES: Command palette caret tint drifted") + print("=" * 60) + print("The command palette search and rename text fields must keep a white caret:") + print() + for message in tint_violations: + print(f" {message}") + print() + print("FIX: Set command palette TextField tint modifiers to `.white`.") + print() + + if has_failures: return 1 - print("✅ No auto-updating Text style patterns found") + print("✅ No linted SwiftUI pattern regressions found") return 0 diff --git a/tests_v2/test_command_palette_shortcut_hint_sync.py b/tests_v2/test_command_palette_shortcut_hint_sync.py new file mode 100644 index 00000000..c6acc01a --- /dev/null +++ b/tests_v2/test_command_palette_shortcut_hint_sync.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette shortcut hints stay in sync with editable shortcuts. + +Validates: +- New Window / Close Window / Rename Tab commands are present in command mode. +- Their displayed shortcut hints reflect the current KeyboardShortcutSettings values. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"command palette did not become visible={visible}", + ) + + +def _palette_results(client: cmux, window_id: str, limit=12) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _open_palette_and_rows(client: cmux, window_id: str, limit: int = 80) -> list: + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + payload = _palette_results(client, window_id, limit=limit) + rows = payload.get("results") or [] + if not rows: + raise cmuxError(f"command palette returned no rows: {payload}") + return rows + + +def _assert_shortcut_hint(rows: list, command_id: str, expected_hint: str) -> None: + row = next((row for row in rows if str((row or {}).get("command_id") or "") == command_id), None) + if row is None: + raise cmuxError(f"missing command palette row for {command_id!r}; rows={rows}") + shortcut_hint = str((row or {}).get("shortcut_hint") or "") + if shortcut_hint != expected_hint: + raise cmuxError( + f"unexpected shortcut hint for {command_id}: expected {expected_hint!r}, got {shortcut_hint!r} row={row}" + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + shortcut_names = ["new_window", "close_window", "rename_tab"] + try: + rows = _open_palette_and_rows(client, window_id) + _assert_shortcut_hint(rows, "palette.newWindow", "⇧⌘N") + _assert_shortcut_hint(rows, "palette.closeWindow", "⌃⌘W") + _assert_shortcut_hint(rows, "palette.renameTab", "⌘R") + + client.set_shortcut("new_window", "cmd+opt+n") + client.set_shortcut("close_window", "cmd+opt+w") + client.set_shortcut("rename_tab", "cmd+ctrl+r") + + rows = _open_palette_and_rows(client, window_id) + _assert_shortcut_hint(rows, "palette.newWindow", "⌥⌘N") + _assert_shortcut_hint(rows, "palette.closeWindow", "⌥⌘W") + _assert_shortcut_hint(rows, "palette.renameTab", "⌃⌘R") + finally: + for name in shortcut_names: + try: + client.set_shortcut(name, "clear") + except cmuxError: + pass + _set_palette_visible(client, window_id, False) + + print("PASS: command-palette shortcut hints track editable shortcuts for new/close/rename window-tab actions") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_lint_swiftui_patterns.py b/tests_v2/test_lint_swiftui_patterns.py index f5d82c14..685480eb 100644 --- a/tests_v2/test_lint_swiftui_patterns.py +++ b/tests_v2/test_lint_swiftui_patterns.py @@ -9,6 +9,7 @@ This test checks for: from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s return violations +def check_command_palette_caret_tint(repo_root: Path) -> List[str]: + """Ensure command palette text inputs keep a white caret tint.""" + content_view = repo_root / "Sources" / "ContentView.swift" + if not content_view.exists(): + return [f"Missing expected file: {content_view}"] + + try: + content = content_view.read_text() + except Exception as e: + return [f"Could not read {content_view}: {e}"] + + checks = [ + ( + "search input", + r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P.*?)" + r"\.focused\(\$isCommandPaletteSearchFocused\)", + ), + ( + "rename input", + r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P.*?)" + r"\.focused\(\$isCommandPaletteRenameFocused\)", + ), + ] + + violations: List[str] = [] + for label, pattern in checks: + match = re.search(pattern, content, flags=re.DOTALL) + if not match: + violations.append( + f"Could not locate command palette {label} TextField block in Sources/ContentView.swift" + ) + continue + + body = match.group("body") + if ".tint(.white)" not in body: + violations.append( + f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift" + ) + + return violations + + def main(): """Run the lint checks.""" repo_root = get_repo_root() @@ -102,15 +145,18 @@ def main(): print(f"Checking {len(swift_files)} Swift files for performance issues...") # Check for auto-updating Text styles - violations = check_autoupdating_text_styles(swift_files) + style_violations = check_autoupdating_text_styles(swift_files) + tint_violations = check_command_palette_caret_tint(repo_root) + has_failures = False - if violations: + if style_violations: + has_failures = True print("\n❌ LINT FAILURES: Auto-updating Text styles found") print("=" * 60) print("These patterns cause continuous SwiftUI view updates and high CPU usage:") print() - for file_path, line_num, line in violations: + for file_path, line_num, line in style_violations: rel_path = file_path.relative_to(repo_root) print(f" {rel_path}:{line_num}") print(f" {line}") @@ -120,9 +166,23 @@ def main(): print(" Instead of: Text(date, style: .time)") print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") print() + + if tint_violations: + has_failures = True + print("\n❌ LINT FAILURES: Command palette caret tint drifted") + print("=" * 60) + print("The command palette search and rename text fields must keep a white caret:") + print() + for message in tint_violations: + print(f" {message}") + print() + print("FIX: Set command palette TextField tint modifiers to `.white`.") + print() + + if has_failures: return 1 - print("✅ No auto-updating Text style patterns found") + print("✅ No linted SwiftUI pattern regressions found") return 0 From 1e8859f92201efdac3db11034c4b8790f47c7036 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 04:13:15 -0800 Subject: [PATCH 034/136] Show Cmd+P switcher results across all windows --- Sources/ContentView.swift | 228 +++++++++++++----- ...st_command_palette_switcher_all_windows.py | 128 ++++++++++ 2 files changed, 295 insertions(+), 61 deletions(-) create mode 100644 tests_v2/test_command_palette_switcher_all_windows.py diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8359f265..ada87fba 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1328,6 +1328,13 @@ struct ContentView: View { var id: String { command.id } } + private struct CommandPaletteSwitcherWindowContext { + let windowId: UUID + let tabManager: TabManager + let selectedWorkspaceId: UUID? + let windowLabel: String? + } + private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot @@ -2792,94 +2799,193 @@ struct ContentView: View { } private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { - var workspaces = tabManager.tabs - guard !workspaces.isEmpty else { return [] } - - if let selectedWorkspaceId = tabManager.selectedTabId, - let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { - let selectedWorkspace = workspaces.remove(at: selectedIndex) - workspaces.insert(selectedWorkspace, at: 0) - } + let windowContexts = commandPaletteSwitcherWindowContexts() + guard !windowContexts.isEmpty else { return [] } var entries: [CommandPaletteCommand] = [] - entries.reserveCapacity(workspaces.count * 4) + let estimatedCount = windowContexts.reduce(0) { partial, context in + partial + max(1, context.tabManager.tabs.count) * 4 + } + entries.reserveCapacity(estimatedCount) var nextRank = 0 - for workspace in workspaces { - let workspaceName = workspaceDisplayName(workspace) - let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" - let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( - baseKeywords: [ - "workspace", - "switch", - "go", - "open", - workspaceName - ], - metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), - detail: .workspace - ) - entries.append( - CommandPaletteCommand( - id: workspaceCommandId, - rank: nextRank, - title: workspaceName, - subtitle: "Workspace", - shortcutHint: nil, - keywords: workspaceKeywords, - dismissOnRun: true, - action: { - tabManager.focusTab(workspace.id, suppressFlash: true) - } - ) - ) - nextRank += 1 + for context in windowContexts { + var workspaces = context.tabManager.tabs + guard !workspaces.isEmpty else { continue } - var orderedPanelIds = workspace.sidebarOrderedPanelIds() - if let focusedPanelId = workspace.focusedPanelId, - let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { - orderedPanelIds.remove(at: focusedIndex) - orderedPanelIds.insert(focusedPanelId, at: 0) + let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId + if let selectedWorkspaceId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) } - for panelId in orderedPanelIds { - guard let panel = workspace.panels[panelId] else { continue } - let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) - let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" - let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + let windowId = context.windowId + let windowTabManager = context.tabManager + let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) + for workspace in workspaces { + let workspaceName = workspaceDisplayName(workspace) + let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( baseKeywords: [ - "tab", - "surface", - "panel", + "workspace", "switch", "go", - workspaceName, - panelTitle, - typeLabel.lowercased() - ], - metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + "open", + workspaceName + ] + windowKeywords, + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + detail: .workspace ) + let workspaceId = workspace.id entries.append( CommandPaletteCommand( - id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + id: workspaceCommandId, rank: nextRank, - title: panelTitle, - subtitle: "\(typeLabel) • \(workspaceName)", + title: workspaceName, + subtitle: commandPaletteSwitcherSubtitle(base: "Workspace", windowLabel: context.windowLabel), shortcutHint: nil, - keywords: panelKeywords, + keywords: workspaceKeywords, dismissOnRun: true, action: { - tabManager.focusTab(workspace.id, surfaceId: panelId, suppressFlash: true) + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId, + panelId: nil + ) } ) ) nextRank += 1 + + var orderedPanelIds = workspace.sidebarOrderedPanelIds() + if let focusedPanelId = workspace.focusedPanelId, + let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { + orderedPanelIds.remove(at: focusedIndex) + orderedPanelIds.insert(focusedPanelId, at: 0) + } + + for panelId in orderedPanelIds { + guard let panel = workspace.panels[panelId] else { continue } + let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) + let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" + let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "tab", + "surface", + "panel", + "switch", + "go", + workspaceName, + panelTitle, + typeLabel.lowercased() + ] + windowKeywords, + metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + ) + entries.append( + CommandPaletteCommand( + id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + rank: nextRank, + title: panelTitle, + subtitle: commandPaletteSwitcherSubtitle( + base: "\(typeLabel) • \(workspaceName)", + windowLabel: context.windowLabel + ), + shortcutHint: nil, + keywords: panelKeywords, + dismissOnRun: true, + action: { + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId, + panelId: panelId + ) + } + ) + ) + nextRank += 1 + } } } return entries } + private func commandPaletteSwitcherWindowContexts() -> [CommandPaletteSwitcherWindowContext] { + let fallback = CommandPaletteSwitcherWindowContext( + windowId: windowId, + tabManager: tabManager, + selectedWorkspaceId: tabManager.selectedTabId, + windowLabel: nil + ) + + guard let appDelegate = AppDelegate.shared else { return [fallback] } + let summaries = appDelegate.listMainWindowSummaries() + guard !summaries.isEmpty else { return [fallback] } + + let orderedSummaries = summaries.sorted { lhs, rhs in + let lhsIsCurrent = lhs.windowId == windowId + let rhsIsCurrent = rhs.windowId == windowId + if lhsIsCurrent != rhsIsCurrent { return lhsIsCurrent } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + var windowLabelById: [UUID: String] = [:] + if orderedSummaries.count > 1 { + for (index, summary) in orderedSummaries.enumerated() where summary.windowId != windowId { + windowLabelById[summary.windowId] = "Window \(index + 1)" + } + } + + var contexts: [CommandPaletteSwitcherWindowContext] = [] + var seenWindowIds: Set = [] + for summary in orderedSummaries { + guard let manager = appDelegate.tabManagerFor(windowId: summary.windowId) else { continue } + guard seenWindowIds.insert(summary.windowId).inserted else { continue } + contexts.append( + CommandPaletteSwitcherWindowContext( + windowId: summary.windowId, + tabManager: manager, + selectedWorkspaceId: summary.selectedWorkspaceId, + windowLabel: windowLabelById[summary.windowId] + ) + ) + } + + if contexts.isEmpty { + return [fallback] + } + return contexts + } + + private func commandPaletteSwitcherSubtitle(base: String, windowLabel: String?) -> String { + guard let windowLabel else { return base } + return "\(base) • \(windowLabel)" + } + + private func commandPaletteWindowKeywords(windowLabel: String?) -> [String] { + guard let windowLabel else { return [] } + return ["window", windowLabel.lowercased()] + } + + private func focusCommandPaletteSwitcherTarget( + windowId: UUID, + tabManager: TabManager, + workspaceId: UUID, + panelId: UUID? + ) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + if let panelId { + tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true) + } else { + tabManager.focusTab(workspaceId, suppressFlash: true) + } + } + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { // Keep workspace rows coarse so surface rows win for directory/branch-specific queries. let directories = [workspace.currentDirectory] diff --git a/tests_v2/test_command_palette_switcher_all_windows.py b/tests_v2/test_command_palette_switcher_all_windows.py new file mode 100644 index 00000000..b779d383 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_all_windows.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher should include workspaces from every window. + +Why: switcher rows were sourced from the current window's TabManager only, so +Cmd+P could not jump to workspaces/tabs owned by other windows. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility in {window_id} did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_a = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_a: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + + window_b = client.new_window() + time.sleep(0.25) + + token_suffix = f"{int(time.time() * 1000)}" + token_a = f"cmdp-window-a-{token_suffix}" + token_b = f"cmdp-window-b-{token_suffix}" + + workspace_a = client.new_workspace(window_id=window_a) + client.rename_workspace(token_a, workspace=workspace_a) + + workspace_b = client.new_workspace(window_id=window_b) + client.rename_workspace(token_b, workspace=workspace_b) + time.sleep(0.25) + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + _set_palette_visible(client, window_a, False) + _set_palette_visible(client, window_b, False) + + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_a), + message="cmd+p did not open palette in window A", + ) + _wait_until( + lambda: str(_palette_results(client, window_a).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode in window A", + ) + + client.simulate_type(token_b) + _wait_until( + lambda: token_b in str(_palette_results(client, window_a).get("query") or "").strip().lower(), + message="switcher query did not update with window B token", + ) + + result_rows = (_palette_results(client, window_a, limit=64).get("results") or []) + target_workspace_command = f"switcher.workspace.{workspace_b.lower()}" + if not any(str((row or {}).get("command_id") or "") == target_workspace_command for row in result_rows): + raise cmuxError( + f"cmd+p switcher in window A did not include workspace from window B " + f"(expected {target_workspace_command}); rows={result_rows[:8]}" + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_a), + message="palette did not close after selecting cross-window switcher row", + ) + _wait_until( + lambda: client.current_workspace().lower() == workspace_b.lower(), + message="Enter on cross-window switcher row did not move to window B workspace", + ) + _wait_until( + lambda: client.current_window().lower() == window_b.lower(), + message="Enter on cross-window switcher row did not focus window B", + ) + + print("PASS: cmd+p switcher includes and navigates to workspaces from other windows") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 82d2d0e474d58817d6f51896c19485961f4ca644 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 04:53:25 -0800 Subject: [PATCH 035/136] Add command palette apply and attempt update actions --- Sources/AppDelegate.swift | 10 ++ Sources/ContentView.swift | 22 ++++ Sources/Update/UpdateController.swift | 38 ++++++ tests/test_command_palette_update_commands.py | 114 ++++++++++++++++++ 4 files changed, 184 insertions(+) create mode 100755 tests/test_command_palette_update_commands.py diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 35af3c73..8cc8586b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1078,6 +1078,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.checkForUpdates() } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } + + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b887a15e..f8efb60a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3330,6 +3330,22 @@ struct ContentView: View { keywords: ["update", "upgrade", "release"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.applyUpdateIfAvailable", + title: constant("Apply Update (If Available)"), + subtitle: constant("Global"), + keywords: ["apply", "install", "update", "available"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.attemptUpdate", + title: constant("Attempt Update"), + subtitle: constant("Global"), + keywords: ["attempt", "check", "update", "upgrade", "release"] + ) + ) contributions.append( CommandPaletteCommandContribution( @@ -3719,6 +3735,12 @@ struct ContentView: View { registry.register(commandId: "palette.checkForUpdates") { AppDelegate.shared?.checkForUpdates(nil) } + registry.register(commandId: "palette.applyUpdateIfAvailable") { + AppDelegate.shared?.applyUpdateIfAvailable(nil) + } + registry.register(commandId: "palette.attemptUpdate") { + AppDelegate.shared?.attemptUpdate(nil) + } registry.register(commandId: "palette.renameWorkspace") { beginRenameWorkspaceFlow() diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 0fc1c4e1..94fae950 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -8,6 +8,8 @@ class UpdateController { private(set) var updater: SPUUpdater private let userDriver: UpdateDriver private var installCancellable: AnyCancellable? + private var attemptInstallCancellable: AnyCancellable? + private var didObserveAttemptUpdateProgress: Bool = false private var noUpdateDismissCancellable: AnyCancellable? private var noUpdateDismissWorkItem: DispatchWorkItem? private var readyCheckWorkItem: DispatchWorkItem? @@ -46,6 +48,7 @@ class UpdateController { deinit { installCancellable?.cancel() + attemptInstallCancellable?.cancel() noUpdateDismissCancellable?.cancel() noUpdateDismissWorkItem?.cancel() readyCheckWorkItem?.cancel() @@ -107,6 +110,35 @@ class UpdateController { } } + /// Check for updates and auto-confirm install if one is found. + func attemptUpdate() { + stopAttemptUpdateMonitoring() + didObserveAttemptUpdateProgress = false + + attemptInstallCancellable = viewModel.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + + if state.isInstallable || !state.isIdle { + self.didObserveAttemptUpdateProgress = true + } + + if case .updateAvailable = state { + UpdateLogStore.shared.append("attemptUpdate auto-confirming available update") + state.confirm() + return + } + + guard self.didObserveAttemptUpdateProgress, !state.isInstallable else { + return + } + self.stopAttemptUpdateMonitoring() + } + + checkForUpdates() + } + /// Check for updates (used by the menu item). @objc func checkForUpdates() { UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") @@ -175,6 +207,12 @@ class UpdateController { return true } + private func stopAttemptUpdateMonitoring() { + attemptInstallCancellable?.cancel() + attemptInstallCancellable = nil + didObserveAttemptUpdateProgress = false + } + private func installNoUpdateDismissObserver() { noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState) .receive(on: DispatchQueue.main) diff --git a/tests/test_command_palette_update_commands.py b/tests/test_command_palette_update_commands.py new file mode 100755 index 00000000..b126d6e8 --- /dev/null +++ b/tests/test_command_palette_update_commands.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Regression test for command-palette update command wiring.""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None: + if re.search(pattern, content, flags=re.DOTALL) is None: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift" + + missing_paths = [ + str(path) + for path in [content_view_path, app_delegate_path, controller_path] + if not path.exists() + ] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + content_view = read_text(content_view_path) + app_delegate = read_text(app_delegate_path) + controller = read_text(controller_path) + + failures: list[str] = [] + + expect_regex( + content_view, + r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\]', + "Missing or incomplete `palette.applyUpdateIfAvailable` contribution", + failures, + ) + expect_regex( + content_view, + r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]', + "Missing or incomplete `palette.attemptUpdate` contribution", + failures, + ) + expect_regex( + content_view, + r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}', + "Missing handler registration for `palette.applyUpdateIfAvailable`", + failures, + ) + expect_regex( + content_view, + r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}', + "Missing handler registration for `palette.attemptUpdate`", + failures, + ) + + expect_regex( + app_delegate, + r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}', + "`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`", + failures, + ) + expect_regex( + app_delegate, + r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}', + "`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`", + failures, + ) + + expect_regex( + controller, + r'func\s+attemptUpdate\(\)\s*\{', + "`UpdateController.attemptUpdate()` is missing", + failures, + ) + if "state.confirm()" not in controller: + failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation") + if "checkForUpdates()" not in controller: + failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install") + + if failures: + print("FAIL: command-palette update command regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: command-palette update commands expose apply + attempt wiring") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From bf69fcbdcf6b7a10b4987b8679371b0da1a31b73 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 04:56:48 -0800 Subject: [PATCH 036/136] Hide apply-update command until update is available --- Sources/ContentView.swift | 9 ++++++++- tests/test_command_palette_update_commands.py | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f8efb60a..21b5f01b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1275,6 +1275,8 @@ struct ContentView: View { static let panelHasCustomName = "panel.hasCustomName" static let panelShouldPin = "panel.shouldPin" static let panelHasUnread = "panel.hasUnread" + + static let updateHasAvailable = "update.hasAvailable" } private struct CommandPaletteCommandContribution { @@ -3190,6 +3192,10 @@ struct ContentView: View { snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) } + if case .updateAvailable = updateViewModel.effectiveState { + snapshot.setBool(CommandPaletteContextKeys.updateHasAvailable, true) + } + return snapshot } @@ -3335,7 +3341,8 @@ struct ContentView: View { commandId: "palette.applyUpdateIfAvailable", title: constant("Apply Update (If Available)"), subtitle: constant("Global"), - keywords: ["apply", "install", "update", "available"] + keywords: ["apply", "install", "update", "available"], + when: { $0.bool(CommandPaletteContextKeys.updateHasAvailable) } ) ) contributions.append( diff --git a/tests/test_command_palette_update_commands.py b/tests/test_command_palette_update_commands.py index b126d6e8..f5035037 100755 --- a/tests/test_command_palette_update_commands.py +++ b/tests/test_command_palette_update_commands.py @@ -53,8 +53,20 @@ def main() -> int: expect_regex( content_view, - r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\]', - "Missing or incomplete `palette.applyUpdateIfAvailable` contribution", + r'static\s+let\s+updateHasAvailable\s*=\s*"update\.hasAvailable"', + "Missing `CommandPaletteContextKeys.updateHasAvailable`", + failures, + ) + expect_regex( + content_view, + r'if\s+case\s+\.updateAvailable\s*=\s*updateViewModel\.effectiveState\s*\{\s*snapshot\.setBool\(CommandPaletteContextKeys\.updateHasAvailable,\s*true\)\s*\}', + "Command palette context no longer tracks update-available state", + failures, + ) + expect_regex( + content_view, + r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\].*?when:\s*\{\s*\$0\.bool\(CommandPaletteContextKeys\.updateHasAvailable\)\s*\}', + "Missing or incomplete `palette.applyUpdateIfAvailable` contribution visibility gating", failures, ) expect_regex( From 5070b137a4c920361c62a38a9de1fcecab539a09 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:19:58 -0800 Subject: [PATCH 037/136] Fix early Cmd+D then Ctrl+D split startup hang (#364) * Harden early Ctrl+D child-exit callback routing * Add Ctrl+D close-path tracing and suppress tiny-frame focus churn * Suppress hidden-surface onFocus callbacks during portal churn --- Sources/AppDelegate.swift | 8 + Sources/GhosttyTerminalView.swift | 175 ++++++++++++++------ Sources/TabManager.swift | 22 ++- cmuxUITests/CloseWorkspaceCmdDUITests.swift | 13 ++ 4 files changed, 165 insertions(+), 53 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8cc8586b..f6811ae8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2190,8 +2190,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Keep keyboard routing deterministic after split close/reparent transitions: // before processing shortcuts, converge first responder with the focused terminal panel. if isControlD { +#if DEBUG + let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") +#endif tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() #if DEBUG + let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) #endif // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c3c369f7..38998db0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -229,9 +229,21 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? private final class GhosttySurfaceCallbackContext { weak var surfaceView: GhosttyNSView? + weak var terminalSurface: TerminalSurface? + let surfaceId: UUID - init(surfaceView: GhosttyNSView) { + init(surfaceView: GhosttyNSView, terminalSurface: TerminalSurface) { self.surfaceView = surfaceView + self.terminalSurface = terminalSurface + self.surfaceId = terminalSurface.id + } + + var tabId: UUID? { + terminalSurface?.tabId ?? surfaceView?.tabId + } + + var runtimeSurface: ghostty_surface_t? { + terminalSurface?.surface ?? surfaceView?.terminalSurface?.surface } } @@ -433,8 +445,8 @@ class GhosttyApp { } runtimeConfig.read_clipboard_cb = { userdata, location, state in // Read clipboard - guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } - guard let surface = surfaceView.terminalSurface?.surface else { return } + guard let callbackContext = GhosttyApp.callbackContext(from: userdata), + let surface = callbackContext.runtimeSurface else { return } let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) let value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? "" @@ -445,8 +457,8 @@ class GhosttyApp { } runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in guard let content else { return } - guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } - guard let surface = surfaceView.terminalSurface?.surface else { return } + guard let callbackContext = GhosttyApp.callbackContext(from: userdata), + let surface = callbackContext.runtimeSurface else { return } ghostty_surface_complete_clipboard_request(surface, content, state, true) } @@ -478,16 +490,16 @@ class GhosttyApp { } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in - guard let surfaceView = GhosttyApp.surfaceView(from: userdata) else { return } - let callbackSurfaceId = surfaceView.terminalSurface?.id - let callbackTabId = surfaceView.tabId + guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return } + let callbackSurfaceId = callbackContext.surfaceId + let callbackTabId = callbackContext.tabId #if DEBUG cmuxWriteChildExitProbe( [ "probeCloseSurfaceNeedsConfirm": needsConfirmClose ? "1" : "0", "probeCloseSurfaceTabId": callbackTabId?.uuidString ?? "", - "probeCloseSurfaceSurfaceId": callbackSurfaceId?.uuidString ?? "", + "probeCloseSurfaceSurfaceId": callbackSurfaceId.uuidString, ], increments: ["probeCloseSurfaceCbCount": 1] ) @@ -498,7 +510,6 @@ class GhosttyApp { // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. if let callbackTabId, - let callbackSurfaceId, let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { @@ -876,10 +887,9 @@ class GhosttyApp { } } - private static func surfaceView(from userdata: UnsafeMutableRawPointer?) -> GhosttyNSView? { + private static func callbackContext(from userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceCallbackContext? { guard let userdata else { return nil } - let context = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - return context.surfaceView + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { @@ -959,16 +969,55 @@ class GhosttyApp { return false } - let surfaceView = Self.surfaceView(from: ghostty_surface_userdata(target.target.surface)) - guard let surfaceView else { return false } + let callbackContext = Self.callbackContext(from: ghostty_surface_userdata(target.target.surface)) + let callbackTabId = callbackContext?.tabId + let callbackSurfaceId = callbackContext?.surfaceId + + if action.tag == GHOSTTY_ACTION_SHOW_CHILD_EXITED { + // The child (shell) exited. Ghostty will fall back to printing + // "Process exited. Press any key..." into the terminal unless the host + // handles this action. For cmux, the correct behavior is to close + // the panel immediately (no prompt). +#if DEBUG + dlog( + "surface.action.showChildExited tab=\(callbackTabId?.uuidString.prefix(5) ?? "nil") " + + "surface=\(callbackSurfaceId?.uuidString.prefix(5) ?? "nil")" + ) +#endif +#if DEBUG + cmuxWriteChildExitProbe( + [ + "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", + "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", + ], + increments: ["probeShowChildExitedCount": 1] + ) +#endif + // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still + // dispatching this action callback. + DispatchQueue.main.async { + guard let app = AppDelegate.shared else { return } + if let callbackTabId, + let callbackSurfaceId, + let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), + workspace.panels[callbackSurfaceId] != nil { + manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) + } + } + // Always report handled so Ghostty doesn't print the fallback prompt. + return true + } + + guard let surfaceView = callbackContext?.surfaceView else { return false } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { logAction( action, target: target, - tabId: surfaceView.tabId, - surfaceId: surfaceView.terminalSurface?.id + tabId: callbackTabId ?? surfaceView.tabId, + surfaceId: callbackSurfaceId ?? surfaceView.terminalSurface?.id ) } @@ -1133,36 +1182,6 @@ class GhosttyApp { ) } return true - case GHOSTTY_ACTION_SHOW_CHILD_EXITED: - // The child (shell) exited. Ghostty will fall back to printing - // "Process exited. Press any key..." into the terminal unless the host - // handles this action. For cmux, the correct behavior is to close - // the panel immediately (no prompt). - let callbackTabId = surfaceView.tabId - let callbackSurfaceId = surfaceView.terminalSurface?.id -#if DEBUG - cmuxWriteChildExitProbe( - [ - "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", - "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", - ], - increments: ["probeShowChildExitedCount": 1] - ) -#endif - // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still - // dispatching this action callback. - DispatchQueue.main.async { - guard let app = AppDelegate.shared else { return } - if let callbackTabId, - let callbackSurfaceId, - let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, - let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), - workspace.panels[callbackSurfaceId] != nil { - manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) - } - } - // Always report handled so Ghostty doesn't print the fallback prompt. - return true case GHOSTTY_ACTION_COLOR_CHANGE: if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change @@ -1591,7 +1610,7 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) - let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view)) + let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view, terminalSurface: self)) surfaceConfig.userdata = callbackContext.toOpaque() surfaceCallbackContext?.release() surfaceCallbackContext = callbackContext @@ -2037,6 +2056,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private var lastSizeSkipSignature: String? #endif + private var hasUsableFocusGeometry: Bool { + bounds.width > 1 && bounds.height > 1 + } + // Visibility is used for focus gating, not for libghostty occlusion. fileprivate var isVisibleInUI: Bool { visibleInUI } fileprivate func setVisibleInUI(_ visible: Bool) { @@ -2435,8 +2458,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // focus/selection can converge. Previously this was gated on `surface != nil`, which // allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit) // stayed behind. - if isVisibleInUI { + let hiddenInHierarchy = isHiddenOrHasHiddenAncestor + if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { onFocus?() + } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { +#if DEBUG + dlog( + "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)" + ) +#endif } } if result, let surface = ensureSurfaceReadyForInput() { @@ -2690,15 +2721,23 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) let text = (event.charactersIgnoringModifiers ?? event.characters ?? "") + let handled: Bool if text.isEmpty { keyEvent.text = nil - _ = ghostty_surface_key(surface, keyEvent) + handled = ghostty_surface_key(surface, keyEvent) } else { - text.withCString { ptr in + handled = text.withCString { ptr in keyEvent.text = ptr - _ = ghostty_surface_key(surface, keyEvent) + return ghostty_surface_key(surface, keyEvent) } } +#if DEBUG + dlog( + "key.ctrl path=ghostty surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "handled=\(handled ? 1 : 0) keyCode=\(event.keyCode) chars=\(cmuxScalarHex(event.characters)) " + + "ign=\(cmuxScalarHex(event.charactersIgnoringModifiers)) mods=\(event.modifierFlags.rawValue)" + ) +#endif return } @@ -4143,6 +4182,12 @@ final class GhosttySurfaceScrollView: NSView { } } + let hasUsablePortalGeometry: Bool = { + let size = bounds.size + return size.width > 1 && size.height > 1 + }() + let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor + guard isActive else { return } guard surfaceView.terminalSurface?.searchState == nil else { return } guard let window else { return } @@ -4150,6 +4195,17 @@ final class GhosttySurfaceScrollView: NSView { retry() return } + guard !isHiddenForFocus, hasUsablePortalGeometry else { +#if DEBUG + dlog( + "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)" + ) +#endif + retry() + return + } guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, @@ -4207,8 +4263,23 @@ final class GhosttySurfaceScrollView: NSView { } private func applyFirstResponderIfNeeded() { + let hasUsablePortalGeometry: Bool = { + let size = bounds.size + return size.width > 1 && size.height > 1 + }() + let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor + guard isActive else { return } guard surfaceView.isVisibleInUI else { return } + guard !isHiddenForFocus, hasUsablePortalGeometry else { +#if DEBUG + dlog( + "focus.apply.skip surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" + ) +#endif + return + } guard surfaceView.terminalSurface?.searchState == nil else { return } guard let window, window.isKeyWindow else { return } if let fr = window.firstResponder as? NSView, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 212040a3..17f13cb3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1137,11 +1137,24 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } +#if DEBUG + dlog( + "surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)" + ) +#endif + // Keep AppKit first responder in sync with workspace focus before routing the close. // If split reparenting caused a temporary model/view mismatch, fallback close logic in // Workspace.closePanel uses focused selection to resolve the correct tab deterministically. reconcileFocusedPanelFromFirstResponderForKeyboard() - _ = tab.closePanel(surfaceId, force: true) + let closed = tab.closePanel(surfaceId, force: true) +#if DEBUG + dlog( + "surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)" + ) +#endif AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) } @@ -1153,6 +1166,13 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } +#if DEBUG + dlog( + "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)" + ) +#endif + // Child-exit on the last panel should collapse the workspace, matching explicit close // semantics (and close the window when it was the last workspace). if tab.panels.count <= 1 { diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index 6955591a..578b3005 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -582,12 +582,25 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { let closedWorkspace = (done["closedWorkspace"] ?? "") == "1" let timedOut = (done["timedOut"] ?? "") == "1" let triggerMode = done["autoTriggerMode"] ?? "" + let exitPanelId = done["exitPanelId"] ?? "" + let workspaceId = done["workspaceId"] ?? "" + let probeSurfaceId = done["probeShowChildExitedSurfaceId"] ?? "" + let probeTabId = done["probeShowChildExitedTabId"] ?? "" XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)") XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)") XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)") XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)") XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)") + if let showChildExitedCount = Int(done["probeShowChildExitedCount"] ?? "") { + XCTAssertEqual(showChildExitedCount, 1, "Attempt \(attempt): expected exactly one SHOW_CHILD_EXITED callback for one early Ctrl+D. data=\(done)") + } + if !exitPanelId.isEmpty, !probeSurfaceId.isEmpty { + XCTAssertEqual(probeSurfaceId, exitPanelId, "Attempt \(attempt): SHOW_CHILD_EXITED should target the split opened by Cmd+D. data=\(done)") + } + if !workspaceId.isEmpty, !probeTabId.isEmpty { + XCTAssertEqual(probeTabId, workspaceId, "Attempt \(attempt): SHOW_CHILD_EXITED should resolve to the active workspace. data=\(done)") + } XCTAssertTrue( waitForWindowCount(app: app, atLeast: 1, timeout: 2.0), "Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)" From 2426590e9156ae813c5a1c06daeb2ca7e7e6cd18 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:50:10 -0800 Subject: [PATCH 038/136] Fix terminal focus when window loses key (#359) * Fix terminal blur when window loses key * Fix shortcut routing across multiple windows * Fix shortcut window detection when identifiers change --- Sources/AppDelegate.swift | 171 +++++++++++++++++- Sources/GhosttyTerminalView.swift | 9 +- Sources/cmuxApp.swift | 54 +++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 165 +++++++++++++++++ 4 files changed, 369 insertions(+), 30 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f6811ae8..87a25c04 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -592,6 +592,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState: SidebarSelectionState ) { let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif if let existing = mainWindowContexts[key] { existing.window = window } else { @@ -615,6 +618,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPaletteSelectionByWindowId[windowId] = 0 commandPaletteSnapshotByWindowId[windowId] = .empty +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif if window.isKeyWindow { setActiveMainWindow(window) } @@ -844,6 +852,117 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return mainWindowContexts[ObjectIdentifier(window)] } +#if DEBUG + private func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) + } + + private func debugWindowToken(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" + let ident = window.identifier?.rawValue ?? "nil" + let shortIdent: String + if ident.count > 120 { + shortIdent = String(ident.prefix(120)) + "..." + } else { + shortIdent = ident + } + return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" + } + + private func debugContextToken(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 + return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" + } + + private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { + let activeManager = tabManager + let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" + let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + + let contexts = mainWindowContexts.values + .map { context in + let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" + let window = context.window ?? windowForMainWindowId(context.windowId) + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" + } + .sorted() + .joined(separator: ",") + + let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" + let eventWindow = event?.window + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + } +#endif + + private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let window = event.window, isMainTerminalWindow(window) { + return window + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber), + isMainTerminalWindow(numberedWindow) { + return numberedWindow + } + if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { + return mainWindow + } + return nil + } + + /// Re-sync app-level active window pointers from the currently focused main terminal window. + /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. + @discardableResult + func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { + let (context, source): (MainWindowContext?, String) = { + if let preferredWindow, + let context = contextForMainWindow(preferredWindow) { + return (context, "preferredWindow") + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return (context, "keyWindow") + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return (context, "mainWindow") + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return (activeContext, "activeManager") + } + return (mainWindowContexts.values.first, "firstContextFallback") + }() + +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) + dlog( + "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + guard let context else { return tabManager } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } +#if DEBUG + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + return context.tabManager + } + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { if let context = contextForMainWindow(event.window) { return context @@ -854,13 +973,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let context = contextForMainWindow(NSApp.mainWindow) { return context } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return activeContext + } return mainWindowContexts.values.first } private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { - guard let context = preferredMainWindowContextForShortcuts(event: event), - let window = context.window ?? windowForMainWindowId(context.windowId) else { return } - setActiveMainWindow(window) + let preferredWindow = mainWindowForShortcutEvent(event) +#if DEBUG + dlog( + "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) +#if DEBUG + dlog( + "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif } @discardableResult @@ -1838,7 +1970,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") } let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } @@ -2247,6 +2381,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { +#if DEBUG + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") +#endif // Cmd+N semantics: // - If there are no main windows, create a new window. // - Otherwise, create a new workspace in the active window. @@ -2357,6 +2494,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let manager = tabManager, let num = Int(chars), let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) { +#if DEBUG + dlog( + "shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) +#endif manager.selectTab(at: targetIndex) return true } @@ -2425,11 +2567,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Split actions: Cmd+D / Cmd+Shift+D if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif _ = performSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif _ = performSplitShortcut(direction: .down) return true } @@ -2807,6 +2955,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2872,6 +3022,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } _ = focusBrowserAddressBar(panelId: panelId) return true @@ -3236,10 +3388,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func setActiveMainWindow(_ window: NSWindow) { guard isMainTerminalWindow(window) else { return } guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + ) +#endif } private func unregisterMainWindow(_ window: NSWindow) { @@ -3282,6 +3442,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } guard let raw = window.identifier?.rawValue else { return false } return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 38998db0..1acbb97e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3713,8 +3713,13 @@ final class GhosttySurfaceScrollView: NSView { object: window, queue: .main ) { [weak self] _ in - // No-op: focus is driven by first-responder changes. - _ = self + guard let self, let window = self.window else { return } + // Losing key window does not always trigger first-responder resignation, so force + // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync. + if let fr = window.firstResponder as? NSView, + fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) { + window.makeFirstResponder(nil) + } }) if window.isKeyWindow { applyFirstResponderIfNeeded() } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a5950c24..23b683de 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -357,7 +357,7 @@ struct cmuxApp: App { } splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).addTab() + activeTabManager.addTab() } } @@ -392,7 +392,7 @@ struct cmuxApp: App { } Button("Reopen Closed Browser Panel") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel() + _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } .keyboardShortcut("t", modifiers: [.command, .shift]) } @@ -401,35 +401,35 @@ struct cmuxApp: App { CommandGroup(after: .textEditing) { Menu("Find") { Button("Find…") { - (AppDelegate.shared?.tabManager ?? tabManager).startSearch() + activeTabManager.startSearch() } .keyboardShortcut("f", modifiers: .command) Button("Find Next") { - (AppDelegate.shared?.tabManager ?? tabManager).findNext() + activeTabManager.findNext() } .keyboardShortcut("g", modifiers: .command) Button("Find Previous") { - (AppDelegate.shared?.tabManager ?? tabManager).findPrevious() + activeTabManager.findPrevious() } .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() Button("Hide Find Bar") { - (AppDelegate.shared?.tabManager ?? tabManager).hideFind() + activeTabManager.hideFind() } .keyboardShortcut("f", modifiers: [.command, .shift]) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible)) + .disabled(!(activeTabManager.isFindVisible)) Divider() Button("Use Selection for Find") { - (AppDelegate.shared?.tabManager ?? tabManager).searchSelection() + activeTabManager.searchSelection() } .keyboardShortcut("e", modifiers: .command) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind)) + .disabled(!(activeTabManager.canUseSelectionForFind)) } } @@ -444,54 +444,54 @@ struct cmuxApp: App { Divider() splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface() + activeTabManager.selectNextSurface() } splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface() + activeTabManager.selectPreviousSurface() } Button("Back") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack() + activeTabManager.focusedBrowserPanel?.goBack() } .keyboardShortcut("[", modifiers: .command) Button("Forward") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward() + activeTabManager.focusedBrowserPanel?.goForward() } .keyboardShortcut("]", modifiers: .command) Button("Reload Page") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload() + activeTabManager.focusedBrowserPanel?.reload() } .keyboardShortcut("r", modifiers: .command) splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if !manager.toggleDeveloperToolsFocusedBrowser() { NSSound.beep() } } splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } Button("Zoom In") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() + _ = activeTabManager.zoomInFocusedBrowser() } .keyboardShortcut("=", modifiers: .command) Button("Zoom Out") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser() + _ = activeTabManager.zoomOutFocusedBrowser() } .keyboardShortcut("-", modifiers: .command) Button("Actual Size") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser() + _ = activeTabManager.resetZoomFocusedBrowser() } .keyboardShortcut("0", modifiers: .command) @@ -500,11 +500,11 @@ struct cmuxApp: App { } splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() + activeTabManager.selectNextTab() } splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab() + activeTabManager.selectPreviousTab() } splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) { @@ -534,7 +534,7 @@ struct cmuxApp: App { // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in Button("Workspace \(number)") { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { manager.selectTab(at: targetIndex) } @@ -705,6 +705,12 @@ struct cmuxApp: App { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } + private var activeTabManager: TabManager { + AppDelegate.shared?.synchronizeActiveMainWindowContext( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) ?? tabManager + } + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { guard !data.isEmpty, let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { @@ -756,11 +762,11 @@ struct cmuxApp: App { window.performClose(nil) return } - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation() + activeTabManager.closeCurrentPanelWithConfirmation() } private func closeTabOrWindow() { - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation() + activeTabManager.closeCurrentTabWithConfirmation() } private func showNotificationsPopover() { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8341c5f1..06f7d6a3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -330,6 +330,127 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +@MainActor +final class AppDelegateWindowContextRoutingTests: XCTestCase { + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowB.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB) + XCTAssertTrue(app.tabManager === managerB) + + windowA.makeKeyAndOrderFront(nil) + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // Seed active manager and clear focus windows to force fallback routing. + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + windowA.orderOut(nil) + windowB.orderOut(nil) + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil) + XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // SwiftUI can replace the NSWindow identifier string at runtime. + window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged") + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window) + XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") + XCTAssertTrue(app.tabManager === manager) + } +} + final class FocusFlashPatternTests: XCTestCase { func testFocusFlashPatternMatchesTerminalDoublePulseShape() { XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) @@ -4241,6 +4362,50 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertTrue(state.isHidden) } + func testWindowResignKeyClearsFocusedTerminalFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + ) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + hostedView.moveFocus() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to be first responder before window blur" + ) + + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Window blur should force terminal surface to resign first responder" + ) + } + func testSearchOverlayMountsAndUnmountsWithSearchState() { let surface = TerminalSurface( tabId: UUID(), From df779d32eaf3d5d3ac3bcd0b9edb0c88cafcea36 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:54:02 -0800 Subject: [PATCH 039/136] Portal: hide terminal view before hidden-frame updates --- Sources/TerminalWindowPortal.swift | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 07831c71..0fbde41d 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -870,6 +870,22 @@ final class WindowTerminalPortal: NSObject { ) } #endif + + // Hide before updating the frame when this entry should not be visible. + // This avoids a one-frame flash of unrendered terminal background when a portal + // briefly transitions through offscreen/tiny geometry during rapid split churn. + if shouldHide, !hostedView.isHidden { +#if DEBUG + dlog( + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + ) +#endif + hostedView.isHidden = true + } + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -877,21 +893,22 @@ final class WindowTerminalPortal: NSObject { CATransaction.commit() if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { + abs(oldFrame.size.height - frameInHost.size.height) > 0.5, + !shouldHide { hostedView.reconcileGeometryNow() } } - if hostedView.isHidden != shouldHide { + if !shouldHide, hostedView.isHidden { #if DEBUG dlog( - "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" ) #endif - hostedView.isHidden = shouldHide + hostedView.isHidden = false } ensureDividerOverlayOnTop() From b34b3a530ae93eb839eabcab9e0b956f9999f168 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:57:23 -0800 Subject: [PATCH 040/136] Portal: log Bonsplit container frame changes --- Sources/TerminalWindowPortal.swift | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 0fbde41d..a2f0c8c8 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -17,6 +17,12 @@ private func portalDebugToken(_ view: NSView?) -> String { private func portalDebugFrame(_ rect: NSRect) -> String { String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) } + +private func portalDebugFrameInWindow(_ view: NSView?) -> String { + guard let view else { return "nil" } + guard view.window != nil else { return "no-window" } + return portalDebugFrame(view.convert(view.bounds, to: nil)) +} #endif final class WindowTerminalHostView: NSView { @@ -536,6 +542,9 @@ final class WindowTerminalPortal: NSObject { private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false +#if DEBUG + private var lastLoggedBonsplitContainerSignature: String? +#endif private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -649,6 +658,35 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } +#if DEBUG + private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { + var current: NSView? = anchorView + while let view = current { + let className = NSStringFromClass(type(of: view)) + if className.contains("PaneDragContainerView") || className.contains("Bonsplit") { + return view + } + current = view.superview + } + return installedReferenceView + } + + private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) { + guard let container = nearestBonsplitContainer(from: anchorView) else { return } + let containerFrame = container.convert(container.bounds, to: nil) + let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))" + guard signature != lastLoggedBonsplitContainerSignature else { return } + lastLoggedBonsplitContainerSignature = signature + + let containerClass = NSStringFromClass(type(of: container)) + dlog( + "portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " + + "class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " + + "host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))" + ) + } +#endif + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -839,6 +877,9 @@ final class WindowTerminalPortal: NSObject { let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) let frameInHost = hostView.convert(frameInWindow, from: nil) +#if DEBUG + logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) +#endif let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && From 3c1f1792c030861b04668b5b79ef402c8978b464 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 10:27:04 -0800 Subject: [PATCH 041/136] Fix browser workspace focus handoff lag (#381) --- Sources/ContentView.swift | 25 ++++++++++++++++++++++++- Sources/Panels/BrowserPanelView.swift | 5 +++-- Sources/Panels/CmuxWebView.swift | 3 +++ Sources/TabManager.swift | 1 + 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 21b5f01b..7fc7b561 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1610,7 +1610,11 @@ struct ContentView: View { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id - let isInputActive = isSelectedWorkspace || isRetiringWorkspace + // Keep the retiring workspace visible during handoff, but never input-active. + // Allowing both selected+retiring workspaces to be input-active lets the + // old workspace steal first responder (notably with WKWebView), which can + // delay handoff completion and make browser returns feel laggy. + let isInputActive = isSelectedWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( @@ -1952,6 +1956,25 @@ struct ContentView: View { completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in + guard let webView = notification.object as? WKWebView, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + let focusedPanelId = selectedWorkspace.focusedPanelId, + let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), + focusedBrowser.webView === webView else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in + guard let panelId = notification.object as? UUID, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + selectedWorkspace.focusedPanelId == panelId, + selectedWorkspace.browserPanel(for: panelId) != nil else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + }) + view = AnyView(view.onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 0294766f..ac19b086 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3070,6 +3070,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator: Coordinator, generation: Int ) { + let retryInterval: TimeInterval = 1.0 / 60.0 // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -3102,7 +3103,7 @@ struct WebViewRepresentable: NSViewRepresentable { // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) { scheduleAttachRetry( webView, panel: panel, @@ -3139,7 +3140,7 @@ struct WebViewRepresentable: NSViewRepresentable { } coordinator.attachRetryWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work) } func updateNSView(_ nsView: NSView, context: Context) { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 83941484..8f2a3a28 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -45,6 +45,9 @@ final class CmuxWebView: WKWebView { return false } let result = super.becomeFirstResponder() + if result { + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + } #if DEBUG let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" dlog( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 17f13cb3..f9c6b17d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3113,6 +3113,7 @@ extension Notification.Name { static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") From 65b3b570c9de069d25011d0539bdc1b2e7dd387d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 10:27:13 -0800 Subject: [PATCH 042/136] Fix Caps Lock handling in browser omnibar keyboard paths (#382) --- Sources/AppDelegate.swift | 28 +++++------ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 87a25c04..0d671e90 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -70,10 +70,9 @@ func browserOmnibarSelectionDeltaForCommandNavigation( chars: String ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } if chars == "n" { return 1 } if chars == "p" { return -1 } return nil @@ -85,9 +84,7 @@ func browserOmnibarSelectionDeltaForArrowNavigation( keyCode: UInt16 ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) guard normalizedFlags == [] else { return nil } switch keyCode { case 125: return 1 @@ -96,10 +93,14 @@ func browserOmnibarSelectionDeltaForArrowNavigation( } } -func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { - let normalizedFlags = flags +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + .subtracting([.numericPad, .function, .capsLock]) +} + +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) return normalizedFlags == [] || normalizedFlags == [.shift] } @@ -2766,10 +2767,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent chars: String ) -> Bool { guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return false } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } return chars == "n" || chars == "p" } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 06f7d6a3..fc1c546f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1123,6 +1123,25 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { ) } + func testArrowNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 126 + ), + -1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 125 + ), + 1 + ) + } + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { XCTAssertNil( browserOmnibarSelectionDeltaForCommandNavigation( @@ -1176,6 +1195,33 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { 1 ) } + + func testCommandNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control, .capsLock], + chars: "n" + ), + 1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .capsLock], + chars: "p" + ), + -1 + ) + } + + func testSubmitOnReturnIgnoresCapsLockModifier() { + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock])) + } } final class BrowserZoomShortcutActionTests: XCTestCase { From d42126a08220c44c024fd7ce2716ff2fe6383729 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 10:51:28 -0800 Subject: [PATCH 043/136] Handle deeplink URL schemes in embedded browser (#392) --- Sources/Panels/BrowserPanel.swift | 41 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 32 +++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6255672e..420ba6d1 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -380,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { return preparedRequest } +private let browserEmbeddedNavigationSchemes: Set = [ + "about", + "applewebdata", + "blob", + "data", + "http", + "https", + "javascript", +] + +func browserShouldOpenURLExternally(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false } + return !browserEmbeddedNavigationSchemes.contains(scheme) +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -2638,6 +2653,22 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } + // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). + // Hand these off to macOS so the owning app can handle them. + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } + // target=_blank or window.open() — navigate in the current webview if navigationAction.targetFrame == nil, navigationAction.request.url != nil { @@ -2761,6 +2792,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url { + if browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index fc1c546f..59208945 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5111,6 +5111,38 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } } +final class BrowserExternalNavigationSchemeTests: XCTestCase { + func testCustomAppSchemesOpenExternally() throws { + let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) + let slack = try XCTUnwrap(URL(string: "slack://open")) + let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join")) + let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com")) + + XCTAssertTrue(browserShouldOpenURLExternally(discord)) + XCTAssertTrue(browserShouldOpenURLExternally(slack)) + XCTAssertTrue(browserShouldOpenURLExternally(zoom)) + XCTAssertTrue(browserShouldOpenURLExternally(mailto)) + } + + func testEmbeddedBrowserSchemesStayInWebView() throws { + let https = try XCTUnwrap(URL(string: "https://example.com")) + let http = try XCTUnwrap(URL(string: "http://example.com")) + let about = try XCTUnwrap(URL(string: "about:blank")) + let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) + let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) + let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) + let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) + + XCTAssertFalse(browserShouldOpenURLExternally(https)) + XCTAssertFalse(browserShouldOpenURLExternally(http)) + XCTAssertFalse(browserShouldOpenURLExternally(about)) + XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(blob)) + XCTAssertFalse(browserShouldOpenURLExternally(javascript)) + XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) + } +} + final class BrowserHostWhitelistTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! From c1822fdaaca52a1d5937ec100bb1821287b5b8f7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 11:14:34 -0800 Subject: [PATCH 044/136] Fix sidebar resize regression with 1/3 width cap (#393) --- Sources/ContentView.swift | 50 ++++++++++++++++++++++++-- cmuxUITests/SidebarResizeUITests.swift | 27 ++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7fc7b561..2325908c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1343,6 +1343,8 @@ struct ContentView: View { ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" private static let commandPaletteCommandsPrefix = ">" + private static let minimumSidebarWidth: CGFloat = 186 + private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 private enum SidebarResizerHandle: Hashable { case divider @@ -1352,8 +1354,31 @@ struct ContentView: View { SidebarResizeInteraction.hitWidthPerSide } - private var maxSidebarWidth: CGFloat { - (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 + private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { + let resolvedAvailableWidth = availableWidth + ?? observedWindow?.contentView?.bounds.width + ?? observedWindow?.contentLayoutRect.width + ?? NSApp.keyWindow?.contentView?.bounds.width + ?? NSApp.keyWindow?.contentLayoutRect.width + if let resolvedAvailableWidth, resolvedAvailableWidth > 0 { + return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio) + } + + let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width + ?? NSScreen.main?.frame.width + ?? 1920 + return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio) + } + + private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth) + ) + guard abs(nextWidth - sidebarWidth) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } } private func activateSidebarResizerCursor() { @@ -1498,6 +1523,7 @@ struct ContentView: View { private func sidebarResizerHandleOverlay( _ handle: SidebarResizerHandle, width: CGFloat, + availableWidth: CGFloat, accessibilityIdentifier: String? = nil ) -> some View { Color.clear @@ -1543,7 +1569,10 @@ struct ContentView: View { activateSidebarResizerCursor() let startWidth = sidebarDragStartWidth ?? sidebarWidth - let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width)) + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width) + ) withTransaction(Transaction(animation: nil)) { sidebarWidth = nextWidth } @@ -1574,6 +1603,7 @@ struct ContentView: View { sidebarResizerHandleOverlay( .divider, width: sidebarResizerHitWidthPerSide * 2, + availableWidth: totalWidth, accessibilityIdentifier: "SidebarResizer" ) @@ -1582,6 +1612,12 @@ struct ContentView: View { .allowsHitTesting(false) } .frame(width: totalWidth, height: proxy.size.height, alignment: .leading) + .onAppear { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } + .onChange(of: totalWidth) { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } } } @@ -2125,6 +2161,13 @@ struct ContentView: View { AppDelegate.shared?.fullscreenControlsViewModel = nil }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === observedWindow else { return } + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + updateSidebarResizerBandState() + }) + view = AnyView(view.onChange(of: sidebarWidth) { _ in updateSidebarResizerBandState() }) @@ -2152,6 +2195,7 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index 57c47214..6844cbeb 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase { XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left") XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset") } + + func testSidebarResizerHasMaximumWidthCap() { + let app = XCUIApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + let elements = app.descendants(matching: .any) + let resizer = elements["SidebarResizer"] + XCTAssertTrue(resizer.waitForExistence(timeout: 5.0)) + + let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let farRight = start.withOffset(CGVector(dx: 5000, dy: 0)) + start.press(forDuration: 0.1, thenDragTo: farRight) + + let windowFrame = window.frame + let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX) + let minimumExpectedRemaining = windowFrame.width * 0.45 + + XCTAssertGreaterThanOrEqual( + remainingWidth, + minimumExpectedRemaining, + "Expected sidebar max-width clamp to leave substantial terminal width. " + + "remaining=\(remainingWidth), window=\(windowFrame.width)" + ) + } } From 6598a38fe33b0b3b8a71802569ca2090184cee80 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 11:26:11 -0800 Subject: [PATCH 045/136] Fix terminal zoom inheritance for new splits/surfaces/workspaces (#384) * Fix terminal Cmd zoom routing for Ghostty focus descendants (#383) * Inherit new terminal zoom from last terminal context Prefer pane-selected terminal as Ghostty config inheritance source when creating splits/new terminals, then focused/fallback terminals. This preserves runtime zoom/font size when opening the next terminal. * Fix terminal zoom inheritance across split/tab/workspace creation --- Sources/AppDelegate.swift | 57 +++- Sources/GhosttyTerminalView.swift | 41 ++- Sources/TabManager.swift | 38 ++- Sources/Workspace.swift | 303 +++++++++++++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 163 ++++++++++ 5 files changed, 551 insertions(+), 51 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0d671e90..11fca42f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -194,6 +194,54 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil } +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView + } + + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView + } + + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView + } + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + current = next.nextResponder + } + + return nil +} + +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView + } + + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView + } + current = candidate.superview + } + + return nil +} + #if DEBUG func browserZoomShortcutTraceCandidate( flags: NSEvent.ModifierFlags, @@ -2300,7 +2348,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept key events — let them flow through to the input method. - if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView, + if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), ghosttyView.hasMarkedText() { return false } @@ -2345,7 +2393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (e.g., split that doesn't properly blur the address bar). If the first responder // is a terminal surface, the address bar can't be focused. if browserAddressBarFocusedPanelId != nil, - NSApp.keyWindow?.firstResponder is GhosttyNSView { + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") #endif @@ -4441,7 +4489,8 @@ private extension NSWindow { // Command shortcuts when the terminal is focused — the local event monitor // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. - if let ghosttyView = self.firstResponder as? GhosttyNSView { + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + if let ghosttyView = firstResponderGhosttyView { // If the IME is composing, don't intercept key events — let them flow // through normal AppKit event dispatch so the input method can process them. if ghosttyView.hasMarkedText() { @@ -4484,7 +4533,7 @@ private extension NSWindow { // When the terminal is focused, skip the full NSWindow.performKeyEquivalent // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. - if self.firstResponder is GhosttyNSView, + if firstResponderGhosttyView != nil, event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), let mainMenu = NSApp.mainMenu { let consumedByMenu = mainMenu.performKeyEquivalent(with: event) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1acbb97e..0e01b157 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1616,6 +1616,13 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext +#if DEBUG + let templateFontText = String(format: "%.2f", surfaceConfig.font_size) + dlog( + "zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "templateFont=\(templateFontText)" + ) +#endif var envVars: [ghostty_env_var_s] = [] var envStorage: [(UnsafeMutablePointer, UnsafeMutablePointer)] = [] defer { @@ -1761,6 +1768,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif return } + guard let createdSurface = surface else { return } // For vsync-driven rendering, Ghostty needs to know which display we're on so it can // start a CVDisplayLink with the right refresh rate. If we don't set this early, the @@ -1772,21 +1780,48 @@ final class TerminalSurface: Identifiable, ObservableObject { if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0 { - ghostty_surface_set_display_id(surface, displayID) + ghostty_surface_set_display_id(createdSurface, displayID) } - ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y) + ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) if wpx > 0, hpx > 0 { - ghostty_surface_set_size(surface, wpx, hpx) + ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx lastXScale = scaleFactors.x lastYScale = scaleFactors.y } + // Some GhosttyKit builds can drop inherited font_size during post-create + // config/scale reconciliation. If runtime points don't match the inherited + // template points, re-apply via binding action so all creation paths + // (new surface, split, new workspace) preserve zoom from the source terminal. + if let inheritedFontPoints = configTemplate?.font_size, + inheritedFontPoints > 0 { + let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface) + let shouldReapply = { + guard let currentFontPoints else { return true } + return abs(currentFontPoints - inheritedFontPoints) > 0.05 + }() + if shouldReapply { + let action = String(format: "set_font_size:%.3f", inheritedFontPoints) + _ = performBindingAction(action) + } + } + flushPendingTextIfNeeded() + +#if DEBUG + let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { + String(format: "%.2f", $0) + } ?? "nil" + dlog( + "zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "runtimeFont=\(runtimeFontText)" + ) +#endif } func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f9c6b17d..0e38e366 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -753,9 +753,15 @@ class TabManager: ObservableObject { @discardableResult func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() + let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) + let newWorkspace = Workspace( + title: "Terminal \(tabs.count + 1)", + workingDirectory: workingDirectory, + portOrdinal: ordinal, + configTemplate: inheritedConfig + ) wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex() if insertIndex >= 0 && insertIndex <= tabs.count { @@ -785,6 +791,36 @@ class TabManager: ObservableObject { @discardableResult func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } + func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { + guard let workspace = selectedWorkspace else { return nil } + if let focusedTerminal = workspace.focusedTerminalPanel { + return focusedTerminal + } + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { + return rememberedTerminal + } + if let focusedPaneId = workspace.bonsplitController.focusedPaneId, + let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { + return paneTerminal + } + return workspace.terminalPanelForConfigInheritance() + } + + private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { + if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { + return cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + } + if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints + return config + } + return nil + } + private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } let normalized = normalizeDirectory(directory) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a0838d0d..9697b271 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,58 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CoreText + +func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { + switch context { + case GHOSTTY_SURFACE_CONTEXT_WINDOW: + return "window" + case GHOSTTY_SURFACE_CONTEXT_TAB: + return "tab" + case GHOSTTY_SURFACE_CONTEXT_SPLIT: + return "split" + default: + return "unknown(\(context))" + } +} + +func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { + guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { + return nil + } + + let ctFont = Unmanaged.fromOpaque(quicklookFont).takeRetainedValue() + let points = Float(CTFontGetSize(ctFont)) + guard points > 0 else { return nil } + return points +} + +func cmuxInheritedSurfaceConfig( + sourceSurface: ghostty_surface_t, + context: ghostty_surface_context_e +) -> ghostty_surface_config_s { + let inherited = ghostty_surface_inherited_config(sourceSurface, context) + var config = inherited + + // Make runtime zoom inheritance explicit, even when Ghostty's + // inherit-font-size config is disabled. + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let points = runtimePoints { + config.font_size = points + } + +#if DEBUG + let inheritedText = String(format: "%.2f", inherited.font_size) + let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil" + let finalText = String(format: "%.2f", config.font_size) + dlog( + "zoom.inherit context=\(cmuxSurfaceContextName(context)) " + + "inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)" + ) +#endif + + return config +} struct SidebarStatusEntry { let key: String @@ -261,6 +313,15 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + /// Last terminal panel used as an inheritance source (typically last focused terminal). + private var lastTerminalConfigInheritancePanelId: UUID? + /// Last known terminal font points from inheritance sources. Used as fallback when + /// no live terminal surface is currently available. + private var lastTerminalConfigInheritanceFontPoints: Float? + /// Per-panel inherited zoom lineage. Descendants reuse this root value unless + /// a panel is explicitly re-zoomed by the user. + private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] + /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? @@ -376,7 +437,12 @@ final class Workspace: Identifiable, ObservableObject { } } - init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { + init( + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ghostty_surface_config_s? = nil + ) { self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title @@ -414,11 +480,13 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? @@ -919,6 +987,169 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Panel Operations + private func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ghostty_surface_config_s? + ) { + guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } + + private func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ghostty_surface_config_s + ) -> Float? { + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { + if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { + // Runtime zoom changed after lineage was seeded (manual zoom on descendant); + // treat runtime as the new root for future descendants. + return runtimePoints + } + return rooted + } + if inheritedConfig.font_size > 0 { + return inheritedConfig.font_size + } + return runtimePoints + } + + private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints + } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints + } + } + + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) + } + + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } + + /// Candidate terminal panels used as the source when creating inherited Ghostty config. + /// Preference order: + /// 1) explicitly preferred terminal panel (when the caller has one), + /// 2) selected terminal in the target pane, + /// 3) currently focused terminal in the workspace, + /// 4) last remembered terminal source, + /// 5) first terminal tab in the target pane, + /// 6) deterministic workspace fallback. + private func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set = [] + + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel, seen.insert(panel.id).inserted else { return } + candidates.append(panel) + } + + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) + } + + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } + + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } + + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } + + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) + } + } + + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) + } + + return candidates + } + + /// Picks the first terminal panel candidate used as the inheritance source. + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } + + private func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ghostty_surface_config_s? { + // Walk candidates in priority order and use the first panel with a live surface. + // This avoids returning nil when the top candidate exists but is not attached yet. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + guard let sourceSurface = terminalPanel.surface.surface else { continue } + var config = cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.font_size = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.font_size > 0 { + lastTerminalConfigInheritanceFontPoints = config.font_size + } + return config + } + + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config + } + + return nil + } + /// Create a new split with a terminal panel @discardableResult func newTerminalSplit( @@ -927,22 +1158,6 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { - // Get inherited config from the source terminal when possible. - // If the split is initiated from a non-terminal panel (for example browser), - // fall back to any terminal in the workspace. - let inheritedConfig: ghostty_surface_config_s? = { - if let sourceTerminal = terminalPanel(for: panelId), - let existing = sourceTerminal.surface.surface { - return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - if let fallbackSurface = panels.values - .compactMap({ ($0 as? TerminalPanel)?.surface.surface }) - .first { - return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - return nil - }() - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? @@ -955,6 +1170,7 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Create the new terminal panel. let newPanel = TerminalPanel( @@ -965,6 +1181,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). @@ -989,6 +1206,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1024,16 +1242,7 @@ final class Workspace: Identifiable, ObservableObject { func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - // Get an existing terminal panel to inherit config from - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) // Create new terminal panel let newPanel = TerminalPanel( @@ -1044,6 +1253,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -1056,6 +1266,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1819,14 +2030,19 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil, + configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -2100,6 +2316,9 @@ extension Workspace: BonsplitDelegate { } panel.focus() + if let terminalPanel = panel as? TerminalPanel { + rememberTerminalConfigInheritanceSource(terminalPanel) + } let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let markedAt = manualUnreadMarkedAt[panelId] if Self.shouldClearManualUnread( @@ -2327,6 +2546,10 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } // Keep the workspace invariant: always retain at least one real panel. // This prevents runtime close callbacks from ever collapsing into a tabless workspace. @@ -2519,15 +2742,7 @@ extension Workspace: BonsplitDelegate { // Keep the existing placeholder tab identity and replace only the panel mapping. // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) let replacementPanel = TerminalPanel( workspaceId: id, @@ -2537,6 +2752,7 @@ extension Workspace: BonsplitDelegate { ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -2579,7 +2795,7 @@ extension Workspace: BonsplitDelegate { // Get the focused terminal in the original pane to inherit config from guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id, let sourcePanelId = panelIdFromSurfaceId(sourceTabId), - let sourcePanel = terminalPanel(for: sourcePanelId) else { return } + terminalPanel(for: sourcePanelId) != nil else { return } #if DEBUG dlog( @@ -2588,11 +2804,10 @@ extension Workspace: BonsplitDelegate { ) #endif - let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface { - ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } else { - nil - } + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) let newPanel = TerminalPanel( workspaceId: id, @@ -2602,6 +2817,7 @@ extension Workspace: BonsplitDelegate { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -2613,6 +2829,7 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 59208945..c12d2c08 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1238,6 +1238,10 @@ final class BrowserZoomShortcutActionTests: XCTestCase { browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), .zoomIn ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), + .zoomIn + ) } func testZoomOutSupportsMinusAndUnderscoreVariants() { @@ -1316,6 +1320,30 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } +final class GhosttyResponderResolutionTests: XCTestCase { + private final class FocusProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + func testResolvesGhosttyViewFromDescendantResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + ghosttyView.addSubview(descendant) + + XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) + } + + func testResolvesGhosttyViewFromGhosttyResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) + } + + func testReturnsNilForUnrelatedResponder() { + let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + XCTAssertNil(cmuxOwningGhosttyView(for: view)) + } +} + final class CommandPaletteKeyboardNavigationTests: XCTestCase { func testArrowKeysMoveSelectionWithoutModifiers() { XCTAssertEqual( @@ -2313,6 +2341,141 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } } +@MainActor +final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { + func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected workspace split setup to succeed") + return + } + + // Programmatic split focuses the new right panel by default. + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftPanelId, + "Expected inheritance to use the selected terminal in the target pane" + ) + } + + func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected workspace browser setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected inheritance to fall back to a terminal in the pane when browser is selected" + ) + } + + func testPreferredTerminalPanelWinsWhenProvided() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a terminal panel") + return + } + + let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected inheritance to prefer last focused terminal when browser is focused in another pane" + ) + } +} + +@MainActor +final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { + func testUsesFocusedTerminalWhenTerminalIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused terminal") + return + } + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testFallsBackToTerminalWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected selected workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" + ) + } + + func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected workspace inheritance source to use last focused terminal across panes" + ) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { From 4bc3da65b67e274855fb5b90723e4b21684200d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Feb 2026 12:19:52 -0800 Subject: [PATCH 046/136] Fix terminal/web portal overflow during narrow pane resizing --- Sources/BrowserWindowPortal.swift | 93 ++++- Sources/GhosttyTerminalView.swift | 98 ++++-- Sources/TerminalWindowPortal.swift | 319 +++++++++++++++++- ...test_terminal_resize_portal_regressions.py | 106 ++++++ vendor/bonsplit | 2 +- 5 files changed, 582 insertions(+), 36 deletions(-) create mode 100644 tests/test_terminal_resize_portal_regressions.py diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 8da7833c..2e82cb66 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -326,6 +326,8 @@ final class WindowBrowserPortal: NSObject { private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var webView: WKWebView? @@ -345,9 +347,73 @@ final class WindowBrowserPortal: NSObject { hostView.layer?.masksToBounds = true hostView.translatesAutoresizingMaskIntoConstraints = true hostView.autoresizingMask = [] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + synchronizeAllWebViews(excluding: nil, source: "externalGeometry") + } + @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } @@ -419,13 +485,32 @@ final class WindowBrowserPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.height - rhs.size.height) <= epsilon } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { frame.minX < bounds.minX - epsilon || frame.minY < bounds.minY - epsilon || @@ -765,7 +850,8 @@ final class WindowBrowserPortal: NSObject { _ = synchronizeHostFrameToReference() let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds let hasFiniteHostBounds = hostBounds.origin.x.isFinite && @@ -838,6 +924,8 @@ final class WindowBrowserPortal: NSObject { CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -952,6 +1040,7 @@ final class WindowBrowserPortal: NSObject { } func tearDown() { + removeGeometryObservers() for webViewId in Array(entriesByWebViewId.keys) { detachWebView(withId: webViewId) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0e01b157..f42d79ae 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1501,6 +1501,17 @@ final class TerminalSurface: Identifiable, ObservableObject { } #endif + /// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived + /// from backing-space points and truncated (never rounded up). + private func pixelDimension(from value: CGFloat) -> UInt32 { + guard value.isFinite else { return 0 } + let floored = floor(max(0, value)) + if floored >= CGFloat(UInt32.max) { + return UInt32.max + } + return UInt32(floored) + } + private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let scale = max( 1.0, @@ -1784,8 +1795,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) - let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) + let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size + let wpx = pixelDimension(from: backingSize.width) + let hpx = pixelDimension(from: backingSize.height) if wpx > 0, hpx > 0 { ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx @@ -1824,12 +1836,21 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } - func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { + func updateSize( + width: CGFloat, + height: CGFloat, + xScale: CGFloat, + yScale: CGFloat, + layerScale: CGFloat, + backingSize: CGSize? = nil + ) { guard let surface = surface else { return } _ = layerScale - let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero)) + let resolvedBackingWidth = backingSize?.width ?? (width * xScale) + let resolvedBackingHeight = backingSize?.height ?? (height * yScale) + let wpx = pixelDimension(from: resolvedBackingWidth) + let hpx = pixelDimension(from: resolvedBackingHeight) guard wpx > 0, hpx > 0 else { return } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) @@ -2114,6 +2135,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func setup() { // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. + wantsLayer = true + layer?.masksToBounds = true installEventMonitor() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) @@ -2241,17 +2264,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ghostty_surface_set_display_id(surface, displayID) } - // Recompute from current bounds after layout, not stale pending sizes. + // Recompute from current bounds after layout. Pending size is only a fallback + // when we don't have usable bounds (e.g. detached/off-window transitions). superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - let targetSize: CGSize = { - let current = bounds.size - if current.width > 0, current.height > 0 { - return current - } - return pendingSurfaceSize ?? current - }() - updateSurfaceSize(size: targetSize) + updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) applyWindowBackgroundIfActive() @@ -2291,9 +2308,30 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override var isOpaque: Bool { false } + private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize { + if let size, + size.width > 0, + size.height > 0 { + return size + } + + let currentBounds = bounds.size + if currentBounds.width > 0, currentBounds.height > 0 { + return currentBounds + } + + if let pending = pendingSurfaceSize, + pending.width > 0, + pending.height > 0 { + return pending + } + + return currentBounds + } + private func updateSurfaceSize(size: CGSize? = nil) { guard let terminalSurface = terminalSurface else { return } - let size = size ?? bounds.size + let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))" @@ -2353,12 +2391,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let xScale = backingSize.width / size.width let yScale = backingSize.height / size.height let layerScale = max(1.0, window.backingScaleFactor) + let drawablePixelSize = CGSize( + width: floor(max(0, backingSize.width)), + height: floor(max(0, backingSize.height)) + ) CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = layerScale + layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { - metalLayer.drawableSize = backingSize + metalLayer.drawableSize = drawablePixelSize } CATransaction.commit() @@ -2367,9 +2410,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { height: size.height, xScale: xScale, yScale: yScale, - layerScale: layerScale + layerScale: layerScale, + backingSize: backingSize ) - pendingSurfaceSize = nil } fileprivate func pushTargetSurfaceSize(_ size: CGSize) { @@ -3559,6 +3602,8 @@ final class GhosttySurfaceScrollView: NSView { documentView.addSubview(surfaceView) super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true backgroundView.wantsLayer = true backgroundView.layer?.backgroundColor = @@ -3696,6 +3741,12 @@ final class GhosttySurfaceScrollView: NSView { synchronizeGeometryAndContent() } + /// Request an immediate terminal redraw after geometry updates so stale IOSurface + /// contents do not remain stretched during live resize churn. + func refreshSurfaceNow() { + surfaceView.terminalSurface?.forceRefresh() + } + private func synchronizeGeometryAndContent() { CATransaction.begin() CATransaction.setDisableActions(true) @@ -3705,7 +3756,6 @@ final class GhosttySurfaceScrollView: NSView { scrollView.frame = bounds let targetSize = scrollView.bounds.size surfaceView.frame.size = targetSize - surfaceView.pushTargetSurfaceSize(targetSize) documentView.frame.size.width = scrollView.bounds.width inactiveOverlayView.frame = bounds if let zone = activeDropZone { @@ -3729,6 +3779,7 @@ final class GhosttySurfaceScrollView: NSView { updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() + synchronizeCoreSurface() } override func viewDidMoveToWindow() { @@ -4606,6 +4657,15 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.frame.origin = visibleRect.origin } + /// Match upstream Ghostty behavior: use content area width (excluding non-content + /// regions such as scrollbar space) when telling libghostty the terminal size. + private func synchronizeCoreSurface() { + let width = scrollView.contentSize.width + let height = surfaceView.frame.height + guard width > 0, height > 0 else { return } + surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) + } + private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 07831c71..cec6847e 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -536,6 +536,8 @@ final class WindowTerminalPortal: NSObject { private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -550,13 +552,141 @@ final class WindowTerminalPortal: NSObject { init(window: NSWindow) { self.window = window super.init() - hostView.wantsLayer = false + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.postsFrameChangedNotifications = true + hostView.postsBoundsChangedNotifications = true hostView.translatesAutoresizingMaskIntoConstraints = false dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true dividerOverlayView.autoresizingMask = [.width, .height] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.boundsDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeLayoutHierarchy() { + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + _ = synchronizeHostFrameToReference() + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "portal.hostFrame.update host=\(portalDebugToken(hostView)) " + + "frame=\(portalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() + synchronizeAllHostedViews(excluding: nil) + + // During live resize, AppKit can deliver frame churn where host/container geometry + // settles a tick before the terminal's own scroll/surface hierarchy. Force a final + // in-place geometry + surface refresh for all visible entries in this window. + for entry in entriesByHostedId.values { + guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } + } + private func ensureDividerOverlayOnTop() { if dividerOverlayView.superview !== hostView { dividerOverlayView.frame = hostView.bounds @@ -605,6 +735,8 @@ final class WindowTerminalPortal: NSObject { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } + synchronizeLayoutHierarchy() + _ = synchronizeHostFrameToReference() ensureDividerOverlayOnTop() return true @@ -634,13 +766,32 @@ final class WindowTerminalPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.height - rhs.size.height) <= epsilon } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { guard let viewIndex = container.subviews.firstIndex(of: view), let referenceIndex = container.subviews.firstIndex(of: reference) else { @@ -649,6 +800,58 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when + /// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective + /// visible rect that should drive portal geometry. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + + private func seededFrameInHost(for anchorView: NSView) -> NSRect? { + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + guard hasFiniteFrame else { return nil } + + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + if hasFiniteHostBounds { + let clampedFrame = frameInHost.intersection(hostBounds) + if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 { + return clampedFrame + } + } + + return frameInHost + } + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -740,6 +943,32 @@ final class WindowTerminalPortal: NSObject { } #endif + _ = synchronizeHostFrameToReference() + + // Seed frame/bounds before entering the window so a freshly reparented + // surface doesn't do a transient 800x600 size update on viewDidMoveToWindow. + if let seededFrame = seededFrameInHost(for: anchorView), + seededFrame.width > 0, + seededFrame.height > 0 { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = seededFrame + hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size) + CATransaction.commit() + } else { + // If anchor geometry is still unsettled, keep this hidden/zero-sized until + // synchronizeHostedView resolves a valid target frame on the next layout tick. + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = .zero + hostedView.bounds = .zero + CATransaction.commit() + hostedView.isHidden = true + } + // Keep inner scroll/surface geometry in sync with the seeded outer frame + // before the hosted view enters a window. + hostedView.reconcileGeometryNow() + if hostedView.superview !== hostView { #if DEBUG dlog( @@ -765,10 +994,13 @@ final class WindowTerminalPortal: NSObject { ensureDividerOverlayOnTop() synchronizeHostedView(withId: hostedId) + scheduleDeferredFullSynchronizeAll() pruneDeadEntries() } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] @@ -795,6 +1027,7 @@ final class WindowTerminalPortal: NSObject { private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let hostedIds = Array(entriesByHostedId.keys) for hostedId in hostedIds { @@ -837,16 +1070,44 @@ final class WindowTerminalPortal: NSObject { return } - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "portal.sync.defer hosted=\(portalDebugToken(hostedView)) " + + "reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " + + "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + hostedView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } + let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && frameInHost.size.width.isFinite && frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 - let outsideHostBounds = !frameInHost.intersects(hostView.bounds) + let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let outsideHostBounds = !hasVisibleIntersection let shouldHide = !entry.visibleInUI || anchorHidden || @@ -856,29 +1117,45 @@ final class WindowTerminalPortal: NSObject { let oldFrame = hostedView.frame #if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) " + + "raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) + } let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { dlog( "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } else if restoredFromTiny { dlog( "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } #endif - if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) - hostedView.frame = frameInHost + hostedView.frame = targetFrame CATransaction.commit() + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } - if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { - hostedView.reconcileGeometryNow() + if hasFiniteFrame { + let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.bounds = expectedBounds + CATransaction.commit() } } @@ -888,12 +1165,25 @@ final class WindowTerminalPortal: NSObject { "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + - "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" ) #endif hostedView.isHidden = shouldHide } +#if DEBUG + dlog( + "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " + + "target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " + + "hostBounds=\(portalDebugFrame(hostBounds))" + ) +#endif + ensureDividerOverlayOnTop() } @@ -927,6 +1217,7 @@ final class WindowTerminalPortal: NSObject { } func tearDown() { + removeGeometryObservers() for hostedId in Array(entriesByHostedId.keys) { detachHostedView(withId: hostedId) } diff --git a/tests/test_terminal_resize_portal_regressions.py b/tests/test_terminal_resize_portal_regressions.py new file mode 100644 index 00000000..055b5e54 --- /dev/null +++ b/tests/test_terminal_resize_portal_regressions.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Static regression checks for terminal tiny-pane resize/overflow fixes. + +Guards the key invariants for issue #348: +1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds. +2) Surface sizing must prefer live bounds over stale pending values when available. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + portal_path = root / "Sources" / "TerminalWindowPortal.swift" + portal_source = portal_path.read_text(encoding="utf-8") + + if "hostView.layer?.masksToBounds = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView layer clipping") + if "hostView.postsFrameChangedNotifications = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications") + if "hostView.postsBoundsChangedNotifications = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications") + + if "private func synchronizeLayoutHierarchy()" not in portal_source: + failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()") + if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source: + failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()") + if "hostedView.reconcileGeometryNow()" not in extract_block( + portal_source, + "func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)", + ): + failures.append("bind() no longer pre-reconciles hosted geometry before attach") + + sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)") + for required in [ + "let hostBounds = hostView.bounds", + "let clampedFrame = frameInHost.intersection(hostBounds)", + "let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost", + "scheduleDeferredFullSynchronizeAll()", + "hostedView.reconcileGeometryNow()", + "hostedView.refreshSurfaceNow()", + ]: + if required not in sync_block: + failures.append(f"terminal portal sync missing: {required}") + + terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift" + terminal_view_source = terminal_view_path.read_text(encoding="utf-8") + + resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize") + bounds_index = resolved_block.find("let currentBounds = bounds.size") + pending_index = resolved_block.find("if let pending = pendingSurfaceSize") + if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index: + failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize") + + update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)") + if "let size = resolvedSurfaceSize(preferred: size)" not in update_block: + failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()") + + if failures: + print("FAIL: terminal resize/portal regression guards failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: terminal resize/portal regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index c9186860..2d0d05aa 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d +Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849 From f3fc8804684a81b10d48da4a4f01dac6f5838c85 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:58:17 -0800 Subject: [PATCH 047/136] Guard self-hosted CI from fork pull requests --- .github/workflows/ci.yml | 11 +++++++++++ tests/test_ci_self_hosted_guard.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100755 tests/test_ci_self_hosted_guard.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7bb8bc..cd3dc3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,15 @@ on: pull_request: jobs: + workflow-guard-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate self-hosted runner guards + run: ./tests/test_ci_self_hosted_guard.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -26,6 +35,8 @@ jobs: run: bun tsc --noEmit ui-tests: + # Never run self-hosted jobs for fork pull requests. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted concurrency: group: self-hosted-build diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh new file mode 100755 index 00000000..f046141c --- /dev/null +++ b/tests/test_ci_self_hosted_guard.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test for https://github.com/manaflow-ai/cmux/issues/385. +# Ensures self-hosted UI tests are never run for fork pull requests. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" + +EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository" + +if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then + echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE" + echo "Expected line:" + echo " $EXPECTED_IF" + exit 1 +fi + +if ! awk ' + /^ ui-tests:/ { in_ui_tests=1; next } + in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 } + in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 } + in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_self_hosted && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: ui-tests block must keep both self-hosted and fork guard" + exit 1 +fi + +echo "PASS: ui-tests self-hosted fork guard is present" From c5d20ae0320f9a981e835691b7a16ac7b19f0c53 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:02:48 -0800 Subject: [PATCH 048/136] Add Cmd+P open-directory shortcuts for installed apps (#368) * Add smart Cmd+P directory-open app shortcuts * Fix command palette scroll snap and jank * Fix command palette selection-follow scrolling * Use scrollPosition for command palette list scrolling * Remove generic IDE directory command from Cmd+P * Increase command palette max height to 450px --- Sources/AppDelegate.swift | 182 +++++++++ Sources/ContentView.swift | 359 ++++++++---------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 166 ++++---- 3 files changed, 410 insertions(+), 297 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 11fca42f..ccf70a6a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -36,6 +36,188 @@ enum FinderServicePathResolver { } } +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case vscode + case cursor + case windsurf + case antigravity + case finder + case terminal + case iterm2 + case ghostty + case warp + case xcode + case androidStudio + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + static let cachedLiveAvailableTargets: Set = availableTargets(in: .live) + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .vscode: + return "Open Current Directory in VS Code" + case .cursor: + return "Open Current Directory in Cursor" + case .windsurf: + return "Open Current Directory in Windsurf" + case .antigravity: + return "Open Current Directory in Antigravity" + case .finder: + return "Open Current Directory in Finder" + case .terminal: + return "Open Current Directory in Terminal" + case .iterm2: + return "Open Current Directory in iTerm2" + case .ghostty: + return "Open Current Directory in Ghostty" + case .warp: + return "Open Current Directory in Warp" + case .xcode: + return "Open Current Directory in Xcode" + case .androidStudio: + return "Open Current Directory in Android Studio" + case .zed: + return "Open Current Directory in Zed" + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .vscode: + return common + ["vs", "code", "visual", "studio"] + case .cursor: + return common + ["cursor"] + case .windsurf: + return common + ["windsurf"] + case .antigravity: + return common + ["antigravity"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .terminal: + return common + ["terminal", "shell"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .xcode: + return common + ["xcode", "apple"] + case .androidStudio: + return common + ["android", "studio"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + applicationPath(in: environment) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .warp: + return ["/Applications/Warp.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} + enum WorkspaceShortcutMapper { /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2325908c..51c4c694 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -977,14 +977,6 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind return controller } -private struct CommandPaletteRowFramePreferenceKey: PreferenceKey { - static var defaultValue: [Int: CGRect] = [:] - - static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs }) - } -} - enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 @@ -1120,8 +1112,8 @@ struct ContentView: View { @State private var commandPaletteRenameDraft: String = "" @State private var commandPaletteSelectedResultIndex: Int = 0 @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteLastSelectionIndex: Int = 0 - @State private var commandPaletteRowFrames: [Int: CGRect] = [:] + @State private var commandPaletteScrollTargetIndex: Int? + @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @@ -1197,11 +1189,6 @@ struct ContentView: View { case kind } - enum CommandPaletteScrollAnchor: Equatable { - case top - case bottom - } - private struct CommandPaletteTrailingLabel { let text: String let style: CommandPaletteTrailingLabelStyle @@ -1277,6 +1264,10 @@ struct ContentView: View { static let panelHasUnread = "panel.hasUnread" static let updateHasAvailable = "update.hasAvailable" + + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } } private struct CommandPaletteCommandContribution { @@ -2444,7 +2435,7 @@ struct ContentView: View { private var commandPaletteCommandListView: some View { let visibleResults = Array(commandPaletteResults) let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - let commandPaletteListMaxHeight: CGFloat = 216 + let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 let commandPaletteEmptyStateHeight: CGFloat = 44 let commandPaletteListContentHeight = visibleResults.isEmpty @@ -2488,133 +2479,85 @@ struct ContentView: View { Divider() - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) - } else { - ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in - let isSelected = index == selectedIndex - let isHovered = commandPaletteHoveredResultIndex == index - let rowBackground: Color = isSelected - ? Color.accentColor.opacity(0.12) - : (isHovered ? Color.primary.opacity(0.08) : .clear) + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? Color.accentColor.opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) - Button { - runCommandPaletteCommand(result.command) - } label: { - HStack(spacing: 8) { - commandPaletteHighlightedTitleText( - result.command.title, - matchedIndices: result.titleMatchIndices - ) - .font(.system(size: 13, weight: .regular)) - .lineLimit(1) - Spacer() - - if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { - switch trailingLabel.style { - case .shortcut: - Text(trailingLabel.text) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) - case .kind: - Text(trailingLabel.text) - .font(.system(size: 11, weight: .regular)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - .padding(.horizontal, 9) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackground) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: CommandPaletteRowFramePreferenceKey.self, - value: [index: geometry.frame(in: .named("commandPaletteListScroll"))] - ) - } + Button { + runCommandPaletteCommand(result.command) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .id(index) - .onHover { hovering in - if hovering { - commandPaletteHoveredResultIndex = index - } else if commandPaletteHoveredResultIndex == index { - commandPaletteHoveredResultIndex = nil + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } } } } - // Force a fresh row tree per query so rendered labels/actions stay in lockstep. - .id(commandPaletteQuery) - } - .coordinateSpace(name: "commandPaletteListScroll") - .frame(height: commandPaletteListHeight) - .onChange(of: commandPaletteSelectedResultIndex) { _ in - guard !visibleResults.isEmpty else { return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - let previousIndex = commandPaletteLastSelectionIndex - defer { commandPaletteLastSelectionIndex = index } - - guard let anchorDecision = Self.commandPaletteScrollAnchor( - selectedIndex: index, - previousIndex: previousIndex, - resultCount: visibleResults.count, - selectedFrame: commandPaletteRowFrames[index], - viewportHeight: commandPaletteListHeight, - contentHeight: commandPaletteListContentHeight - ) else { return } - - let anchor: UnitPoint - switch anchorDecision { - case .top: - anchor = .top - case .bottom: - anchor = .bottom - } - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.1)) { - proxy.scrollTo(index, anchor: anchor) - } - } - } - .onChange(of: visibleResults.count) { _ in - commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - } - .onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in - commandPaletteRowFrames = frames - guard !visibleResults.isEmpty else { return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: index, - resultCount: visibleResults.count, - selectedFrame: frames[index], - viewportHeight: commandPaletteListHeight, - contentHeight: commandPaletteListContentHeight - ) else { return } - let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.08)) { - proxy.scrollTo(index, anchor: anchor) - } - } } + .scrollTargetLayout() + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .frame(height: commandPaletteListHeight) + .scrollPosition( + id: Binding( + get: { commandPaletteScrollTargetIndex }, + // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. + set: { _ in } + ), + anchor: commandPaletteScrollTargetAnchor + ) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) } // Keep Esc-to-close behavior without showing footer controls. @@ -2629,20 +2572,19 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex - commandPaletteRowFrames = [:] + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { _ in commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: visibleResults.count) { _ in commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { commandPaletteHoveredResultIndex = nil } @@ -3245,18 +3187,29 @@ struct ContentView: View { if let panelContext = focusedPanelContext { let workspace = panelContext.workspace let panelId = panelContext.panelId + let panelIsTerminal = panelContext.panel.panelType == .terminal snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) snapshot.setString( CommandPaletteContextKeys.panelName, panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) ) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) - snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + + if panelIsTerminal { + let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + snapshot.setBool( + CommandPaletteContextKeys.terminalOpenTargetAvailable(target), + availableTargets.contains(target) + ) + } + } } if case .updateAvailable = updateViewModel.effectiveState { @@ -3667,15 +3620,20 @@ struct ContentView: View { ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.terminalOpenDirectory", - title: constant("Open Current Directory in IDE"), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "directory", "open", "ide", "code", "default app"], - when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + contributions.append( + CommandPaletteCommandContribution( + commandId: target.commandPaletteCommandId, + title: constant(target.commandPaletteTitle), + subtitle: terminalPanelSubtitle, + keywords: target.commandPaletteKeywords, + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target)) + } + ) ) - ) + } contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalFind", @@ -3938,9 +3896,11 @@ struct ContentView: View { _ = tabManager.createBrowserSplit(direction: .right, url: url) } - registry.register(commandId: "palette.terminalOpenDirectory") { - if !openFocusedDirectoryInDefaultApp() { - NSSound.beep() + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + registry.register(commandId: target.commandPaletteCommandId) { + if !openFocusedDirectory(in: target) { + NSSound.beep() + } } } registry.register(commandId: "palette.terminalFind") { @@ -4004,61 +3964,43 @@ struct ContentView: View { return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) } - static func commandPaletteScrollAnchor( + static func commandPaletteScrollPositionAnchor( selectedIndex: Int, - previousIndex: Int, - resultCount: Int, - selectedFrame: CGRect?, - viewportHeight: CGFloat, - contentHeight: CGFloat, - epsilon: CGFloat = 0.5 - ) -> CommandPaletteScrollAnchor? { + resultCount: Int + ) -> UnitPoint? { guard resultCount > 0 else { return nil } - guard contentHeight > viewportHeight else { return nil } - - // Always pin edges exactly into view when selection reaches first/last. if selectedIndex <= 0 { - return .top + return UnitPoint.top } if selectedIndex >= resultCount - 1 { - return .bottom + return UnitPoint.bottom } - - if let frame = selectedFrame, - frame.minY >= (0 - epsilon), - frame.maxY <= (viewportHeight + epsilon) { - return nil - } - - return selectedIndex >= previousIndex ? .bottom : .top + return nil } - static func commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: Int, - resultCount: Int, - selectedFrame: CGRect?, - viewportHeight: CGFloat, - contentHeight: CGFloat, - epsilon: CGFloat = 0.5 - ) -> CommandPaletteScrollAnchor? { - guard resultCount > 0 else { return nil } - guard contentHeight > viewportHeight else { return nil } - - let isTop = selectedIndex <= 0 - let isBottom = selectedIndex >= (resultCount - 1) - guard isTop || isBottom else { return nil } - - guard let frame = selectedFrame else { - return isTop ? .top : .bottom + private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) { + guard resultCount > 0 else { + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + return } - if isTop { - let topDelta = abs(frame.minY) - return topDelta > epsilon ? .top : nil - } + let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount) + commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor( + selectedIndex: selectedIndex, + resultCount: resultCount + ) - let bottomDelta = abs(frame.maxY - viewportHeight) - return bottomDelta > epsilon ? .bottom : nil + let assignTarget = { + commandPaletteScrollTargetIndex = selectedIndex + } + if animated { + withAnimation(.easeOut(duration: 0.1)) { + assignTarget() + } + } else { + assignTarget() + } } private func moveCommandPaletteSelection(by delta: Int) { @@ -4252,8 +4194,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -4266,8 +4208,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil @@ -4494,9 +4436,22 @@ struct ContentView: View { return NSWorkspace.shared.open(url) } - private func openFocusedDirectoryInDefaultApp() -> Bool { + private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { guard let directoryURL = focusedTerminalDirectoryURL() else { return false } - return NSWorkspace.shared.open(directoryURL) + return openFocusedDirectory(directoryURL, in: target) + } + + private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool { + switch target { + case .finder: + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) + return true + default: + guard let applicationURL = target.applicationURL() else { return false } + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration) + return true + } } private func focusedTerminalDirectoryURL() -> URL? { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c12d2c08..ba914a50 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1491,115 +1491,34 @@ final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { } final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { - func testFirstEntryAlwaysPinsToTopWhenScrollable() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testFirstEntryPinsToTopAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 0, - previousIndex: 1, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) - XCTAssertEqual(anchor, .top) + XCTAssertEqual(anchor, UnitPoint.top) } - func testLastEntryAlwaysPinsToBottomWhenScrollable() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testLastEntryPinsToBottomAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 19, - previousIndex: 18, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) - XCTAssertEqual(anchor, .bottom) + XCTAssertEqual(anchor, UnitPoint.bottom) } - func testFullyVisibleMiddleEntryDoesNotScroll() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testMiddleEntryUsesNilAnchorForMinimalScroll() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 6, - previousIndex: 5, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) XCTAssertNil(anchor) } - func testOutOfViewMiddleEntryUsesDirectionForAnchor() { - let downAnchor = ContentView.commandPaletteScrollAnchor( - selectedIndex: 9, - previousIndex: 8, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(downAnchor, .bottom) - - let upAnchor = ContentView.commandPaletteScrollAnchor( - selectedIndex: 8, - previousIndex: 9, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(upAnchor, .top) - } -} - -final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase { - func testTopEdgeReturnsTopWhenNotPinned() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + func testEmptyResultsProduceNoAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 0, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(anchor, .top) - } - - func testBottomEdgeReturnsBottomWhenNotPinned() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 19, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(anchor, .bottom) - } - - func testPinnedTopAndBottomReturnNil() { - let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 0, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertNil(topAnchor) - - let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 19, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertNil(bottomAnchor) - } - - func testMiddleSelectionNeverForcesCorrection() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 8, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 0 ) XCTAssertNil(anchor) } @@ -3292,6 +3211,63 @@ final class FinderServicePathResolverTests: XCTestCase { } } +final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set, + homeDirectoryPath: String = "/Users/tester" + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) } + ) + } + + func testAvailableTargetsDetectSystemApplications() { + let env = environment( + existingPaths: [ + "/Applications/Visual Studio Code.app", + "/System/Library/CoreServices/Finder.app", + "/System/Applications/Utilities/Terminal.app", + "/Applications/Zed Preview.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.finder)) + XCTAssertTrue(availableTargets.contains(.terminal)) + XCTAssertTrue(availableTargets.contains(.zed)) + XCTAssertFalse(availableTargets.contains(.cursor)) + } + + func testAvailableTargetsFallbackToUserApplications() { + let env = environment( + existingPaths: [ + "/Users/tester/Applications/Cursor.app", + "/Users/tester/Applications/Warp.app", + "/Users/tester/Applications/Android Studio.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.cursor)) + XCTAssertTrue(availableTargets.contains(.warp)) + XCTAssertTrue(availableTargets.contains(.androidStudio)) + XCTAssertFalse(availableTargets.contains(.vscode)) + } + + func testITerm2DetectsLegacyBundleName() { + let env = environment(existingPaths: ["/Applications/iTerm.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) + } + + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { + let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets + XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) + XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) + } +} + final class BrowserSearchEngineTests: XCTestCase { func testGoogleSearchURL() throws { let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) From e9d9c6faf509c759082c3691ce2f958a9ebf878c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:25:16 -0800 Subject: [PATCH 049/136] Preserve startup window geometry when session snapshot is missing --- Sources/AppDelegate.swift | 155 +++++++++++++++++++----- cmuxTests/SessionPersistenceTests.swift | 68 +++++++++++ 2 files changed, 191 insertions(+), 32 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 49220f6c..9c8ad174 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -349,6 +349,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let visibleFrame: CGRect } + private struct PersistedWindowGeometry: Codable, Sendable { + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? + } + + private static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v1" + weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? @@ -734,29 +741,94 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent startupSessionSnapshot = SessionPersistenceStore.load() } + private func persistedWindowGeometry( + defaults: UserDefaults = .standard + ) -> PersistedWindowGeometry? { + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil + } + return try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data) + } + + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + guard let frame else { return } + let payload = PersistedWindowGeometry(frame: frame, display: display) + guard let data = try? JSONEncoder().encode(payload) else { return } + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) + } + + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) + ) + } + + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.cmuxDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + return (available, fallback) + } + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { guard !didAttemptStartupSessionRestore else { return } didAttemptStartupSessionRestore = true guard !didHandleExplicitOpenIntentAtStartup else { return } - guard let startupSessionSnapshot else { return } guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } - guard let primaryWindowSnapshot = startupSessionSnapshot.windows.first else { return } - applySessionWindowSnapshot( - primaryWindowSnapshot, - to: primaryContext, - window: primaryWindow - ) + let startupSnapshot = startupSessionSnapshot + let primaryWindowSnapshot = startupSnapshot?.windows.first + if let primaryWindowSnapshot { + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } + } - let additionalWindows = startupSessionSnapshot - .windows - .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) - if !additionalWindows.isEmpty { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - for windowSnapshot in additionalWindows { - _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + if let startupSnapshot { + let additionalWindows = startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } } } } @@ -782,25 +854,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { - let displays = NSScreen.screens.map { screen in - SessionDisplayGeometry( - displayID: screen.cmuxDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) - } - let fallbackDisplay = (NSScreen.main ?? NSScreen.screens.first).map { screen in - SessionDisplayGeometry( - displayID: screen.cmuxDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) - } - + let displays = currentDisplayGeometries() return Self.resolvedWindowFrame( from: snapshot?.frame, display: snapshot?.display, - availableDisplays: displays, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) + } + + nonisolated static func resolvedStartupPrimaryWindowFrame( + primarySnapshot: SessionWindowSnapshot?, + fallbackFrame: SessionRectSnapshot?, + fallbackDisplaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + if let primary = resolvedWindowFrame( + from: primarySnapshot?.frame, + display: primarySnapshot?.display, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) { + return primary + } + + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, fallbackDisplay: fallbackDisplay ) } @@ -1107,6 +1189,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } return false } + if let primaryWindow = snapshot.windows.first { + persistWindowGeometry( + frame: primaryWindow.frame, + display: primaryWindow.display + ) + } return SessionPersistenceStore.save(snapshot) } @@ -4301,6 +4389,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func unregisterMainWindow(_ window: NSWindow) { + // Keep geometry available as a fallback even if the full session snapshot + // is removed when the last window closes. + persistWindowGeometry(from: window) guard let removed = unregisterMainWindowContext(for: window) else { return } commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 266d13dc..1fb01fa0 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -312,6 +312,74 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.height, 350, accuracy: 0.001) } + func testResolvedStartupPrimaryWindowFrameFallsBackToPersistedGeometryWhenPrimaryMissing() { + let fallbackFrame = SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640) + let fallbackDisplay = SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000) + ) + + let restored = AppDelegate.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackFrame, + fallbackDisplaySnapshot: fallbackDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 180, accuracy: 0.001) + XCTAssertEqual(restored.minY, 140, accuracy: 0.001) + XCTAssertEqual(restored.width, 900, accuracy: 0.001) + XCTAssertEqual(restored.height, 640, accuracy: 0.001) + } + + func testResolvedStartupPrimaryWindowFramePrefersPrimarySnapshotOverFallback() { + let primarySnapshot = SessionWindowSnapshot( + frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700), + display: SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ), + tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: nil, workspaces: []), + sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 220) + ) + let fallbackFrame = SessionRectSnapshot(x: 40, y: 30, width: 700, height: 500) + let fallbackDisplay = SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + let display = AppDelegate.SessionDisplayGeometry( + displayID: 1, + frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000) + ) + + let restored = AppDelegate.resolvedStartupPrimaryWindowFrame( + primarySnapshot: primarySnapshot, + fallbackFrame: fallbackFrame, + fallbackDisplaySnapshot: fallbackDisplay, + availableDisplays: [display], + fallbackDisplay: display + ) + + XCTAssertNotNil(restored) + guard let restored else { return } + XCTAssertEqual(restored.minX, 220, accuracy: 0.001) + XCTAssertEqual(restored.minY, 160, accuracy: 0.001) + XCTAssertEqual(restored.width, 980, accuracy: 0.001) + XCTAssertEqual(restored.height, 700, accuracy: 0.001) + } + func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() { let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700) let display = AppDelegate.SessionDisplayGeometry( From 310d807767f7e21f450a4ca70a0e31628efc740a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:28:23 -0800 Subject: [PATCH 050/136] Add VM split-churn fuzz tests and harden portal reveal gating --- Sources/TerminalWindowPortal.swift | 32 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 52 ++ .../test_split_cmd_d_ctrl_d_geometry_fuzz.py | 211 ++++++++ ...split_cmd_d_ctrl_d_two_pane_frame_guard.py | 487 ++++++++++++++++++ 4 files changed, 777 insertions(+), 5 deletions(-) create mode 100644 tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py create mode 100644 tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index a2f0c8c8..4c0dc9c3 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -535,6 +535,10 @@ private final class SplitDividerOverlayView: NSView { @MainActor final class WindowTerminalPortal: NSObject { + private static let tinyHideThreshold: CGFloat = 1 + private static let minimumRevealWidth: CGFloat = 24 + private static let minimumRevealHeight: CGFloat = 18 + private weak var window: NSWindow? private let hostView = WindowTerminalHostView(frame: .zero) private let dividerOverlayView = SplitDividerOverlayView(frame: .zero) @@ -886,7 +890,12 @@ final class WindowTerminalPortal: NSObject { frameInHost.size.width.isFinite && frameInHost.size.height.isFinite let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 + let tinyFrame = + frameInHost.width <= Self.tinyHideThreshold || + frameInHost.height <= Self.tinyHideThreshold + let revealReadyForDisplay = + frameInHost.width >= Self.minimumRevealWidth && + frameInHost.height >= Self.minimumRevealHeight let outsideHostBounds = !frameInHost.intersects(hostView.bounds) let shouldHide = !entry.visibleInUI || @@ -894,6 +903,7 @@ final class WindowTerminalPortal: NSObject { tinyFrame || !hasFiniteFrame || outsideHostBounds + let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay let oldFrame = hostedView.frame #if DEBUG @@ -920,7 +930,7 @@ final class WindowTerminalPortal: NSObject { dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + - "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" ) #endif @@ -935,17 +945,29 @@ final class WindowTerminalPortal: NSObject { if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || abs(oldFrame.size.height - frameInHost.size.height) > 0.5, - !shouldHide { + !shouldHide, + (!hostedView.isHidden || revealReadyForDisplay) { hostedView.reconcileGeometryNow() } } - if !shouldHide, hostedView.isHidden { + if shouldDeferReveal { +#if DEBUG + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + dlog( + "portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " + + "frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))" + ) + } +#endif + } + + if !shouldHide, hostedView.isHidden, revealReadyForDisplay { #if DEBUG dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + - "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" ) #endif diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8341c5f1..92e0fcd0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4364,6 +4364,14 @@ final class GhosttySurfaceOverlayTests: XCTestCase { @MainActor final class TerminalWindowPortalLifecycleTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + func testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -4553,6 +4561,50 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Promoting z-priority should bring an already-visible terminal to front" ) } + + func testHiddenPortalDefersRevealUntilFrameHasUsableSize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let portal = WindowTerminalPortal(window: window) + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220)) + contentView.addSubview(anchor) + + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible") + + // Collapse to a tiny frame first. + anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal") + + // Then restore to a non-zero but still too-small frame. It should remain hidden. + anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue( + hosted.isHidden, + "Portal should defer reveal until geometry reaches a usable size" + ) + + // Once the frame is large enough again, reveal should resume. + anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") + } } @MainActor diff --git a/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py b/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py new file mode 100644 index 00000000..bc0cf9f3 --- /dev/null +++ b/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Fuzz regression: rapid Cmd+D / Ctrl+D churn must not shift the outer bonsplit container frame. + +This targets the user-reported visual shift/flash while spamming split + close. +We treat any drift in x/y/width/height of the outer container frame as a failure. +""" + +from collections import deque +import os +import random +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_FUZZ_SEED", "424242")) +FUZZ_STEPS = int(os.environ.get("CMUX_SPLIT_FUZZ_STEPS", "1400")) +SAMPLES_PER_STEP = int(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLES", "4")) +SAMPLE_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLE_INTERVAL_S", "0.0015")) +ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_FUZZ_ACTION_JITTER_MAX_S", "0.0035")) +BURST_MAX = int(os.environ.get("CMUX_SPLIT_FUZZ_BURST_MAX", "3")) +MAX_PANES = int(os.environ.get("CMUX_SPLIT_FUZZ_MAX_PANES", "10")) +EPSILON = float(os.environ.get("CMUX_SPLIT_FUZZ_EPSILON", "0.0")) +TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_FUZZ_TRACE_TAIL", "40")) +ASSERT_NO_UNDERFLOW = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_UNDERFLOW", "0") == "1" +ASSERT_NO_EMPTY_PANEL = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_EMPTY_PANEL", "0") == "1" + + +def _pane_count(layout_payload: dict) -> int: + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + return len(panes) + + +def _largest_split_frame(layout_payload: dict) -> dict: + selected = layout_payload.get("selectedPanels") or [] + best = None + best_area = -1.0 + + for row in selected: + for split in row.get("splitViews") or []: + frame = split.get("frame") + if not frame: + continue + + try: + x = float(frame.get("x", 0.0)) + y = float(frame.get("y", 0.0)) + width = float(frame.get("width", 0.0)) + height = float(frame.get("height", 0.0)) + except (TypeError, ValueError): + continue + + if width <= 0.0 or height <= 0.0: + continue + + area = width * height + if area > best_area: + best_area = area + best = {"x": x, "y": y, "width": width, "height": height} + + if best is None: + raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}") + return best + + +def _container_frame(layout_payload: dict) -> dict: + container = (layout_payload.get("layout") or {}).get("containerFrame") + if container: + try: + return { + "x": float(container.get("x", 0.0)), + "y": float(container.get("y", 0.0)), + "width": float(container.get("width", 0.0)), + "height": float(container.get("height", 0.0)), + } + except (TypeError, ValueError): + pass + + # Back-compat fallback for older payloads that don't expose containerFrame. + return _largest_split_frame(layout_payload) + + +def _assert_same_frame( + current: dict, + baseline: dict, + *, + step: int, + sample: int, + action: str, + seed: int, + action_index: int, + trace: list[str], +) -> None: + deltas = { + key: abs(float(current[key]) - float(baseline[key])) + for key in ("x", "y", "width", "height") + } + shifted = {k: v for k, v in deltas.items() if v > EPSILON} + if shifted: + raise cmuxError( + "Outer split container shifted during fuzz churn " + f"(step={step}, sample={sample}, action={action}, action_index={action_index}, seed={seed}, " + f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON})" + f"; recent_actions={trace}" + ) + + +def _warm_start_split(c: cmux) -> dict: + # Ensure we have at least one split so the container frame exists in layout_debug. + c.simulate_shortcut("cmd+d") + deadline = time.time() + 2.0 + last = None + while time.time() < deadline: + payload = c.layout_debug() + last = payload + if _pane_count(payload) >= 2: + return payload + time.sleep(0.02) + raise cmuxError(f"Timed out waiting for first split to appear: {last}") + + +def main() -> int: + rng = random.Random(FUZZ_SEED) + recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL)) + total_actions = 0 + + with cmux(SOCKET_PATH) as c: + ws = c.new_workspace() + c.select_workspace(ws) + c.activate_app() + time.sleep(0.2) + + c.reset_bonsplit_underflow_count() + c.reset_empty_panel_count() + + initial = _warm_start_split(c) + baseline = _container_frame(initial) + if _pane_count(initial) < 2: + raise cmuxError("Expected at least 2 panes after warm start split") + + for step in range(1, FUZZ_STEPS + 1): + burst = rng.randint(1, max(1, BURST_MAX)) + + for burst_index in range(1, burst + 1): + before = c.layout_debug() + pane_count = _pane_count(before) + + if pane_count <= 2: + action = "cmd+d" + elif pane_count >= MAX_PANES: + action = "ctrl+d" + else: + # Bias toward split to keep churn dense while still frequently collapsing via ctrl+d. + action = "cmd+d" if rng.random() < 0.60 else "ctrl+d" + + if action == "cmd+d": + c.simulate_shortcut("cmd+d") + else: + # Ctrl+D equivalent sent directly to the focused terminal surface. + c.send_ctrl_d() + + total_actions += 1 + recent_actions.append( + f"step={step}/burst={burst_index}/{burst} panes_before={pane_count} action={action}" + ) + + # Random micro-jitter to emulate uneven key-repeat timing while keeping churn fast. + if ACTION_JITTER_MAX_S > 0: + time.sleep(rng.uniform(0.0, ACTION_JITTER_MAX_S)) + + # Sample repeatedly after each burst to catch transient shifts. + for sample in range(0, SAMPLES_PER_STEP + 1): + payload = c.layout_debug() + current = _container_frame(payload) + _assert_same_frame( + current, + baseline, + step=step, + sample=sample, + action="burst", + seed=FUZZ_SEED, + action_index=total_actions, + trace=list(recent_actions), + ) + if SAMPLE_INTERVAL_S > 0: + time.sleep(rng.uniform(0.0, SAMPLE_INTERVAL_S)) + + underflows = c.bonsplit_underflow_count() + if ASSERT_NO_UNDERFLOW and underflows != 0: + raise cmuxError(f"bonsplit arranged-subview underflow observed during fuzz run: {underflows}") + + flashes = c.empty_panel_count() + if ASSERT_NO_EMPTY_PANEL and flashes != 0: + raise cmuxError(f"EmptyPanelView appeared during fuzz run (count={flashes})") + + print( + "PASS: cmd+d/ctrl+d fuzz geometry invariant " + f"(seed={FUZZ_SEED}, steps={FUZZ_STEPS}, samples={SAMPLES_PER_STEP}, burst_max={BURST_MAX}, " + f"actions={total_actions}, epsilon={EPSILON}, underflows={underflows}, empty_panel={flashes})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py b/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py new file mode 100644 index 00000000..b4413d83 --- /dev/null +++ b/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +""" +Focused fuzz regression for rapid Cmd+D / Ctrl+D churn in a strict 1<->2 pane loop. + +Intent: + - Keep topology limited to one pane or two left/right panes only. + - Run across multiple fresh workspaces. + - Sample layout as fast as the debug socket allows during transitions/holds. + - Fail immediately if outer container x/y/width/height drifts at any sampled frame. +""" + +from collections import deque +import os +import random +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_2PANE_SEED", "20260223")) +WORKSPACES = int(os.environ.get("CMUX_SPLIT_2PANE_WORKSPACES", "3")) +CYCLES_PER_WORKSPACE = int(os.environ.get("CMUX_SPLIT_2PANE_CYCLES", "220")) +TRANSITION_TIMEOUT_S = float(os.environ.get("CMUX_SPLIT_2PANE_TIMEOUT_S", "2.0")) +HOLD_MIN_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MIN_S", "0.003")) +HOLD_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MAX_S", "0.018")) +PRE_ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_ACTION_JITTER_MAX_S", "0.002")) +EPSILON = float(os.environ.get("CMUX_SPLIT_2PANE_EPSILON", "0.0")) +TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_2PANE_TRACE_TAIL", "64")) +LAYOUT_POLL_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_POLL_SLEEP_S", "0.0008")) +LAYOUT_TIMEOUT_RETRIES = int(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRIES", "4")) +LAYOUT_TIMEOUT_RETRY_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRY_SLEEP_S", "0.0015")) +MAX_LAYOUT_TIMEOUTS = int(os.environ.get("CMUX_SPLIT_2PANE_MAX_LAYOUT_TIMEOUTS", "80")) +CTRL_D_RETRY_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_RETRY_INTERVAL_S", "0.18")) +CTRL_D_MAX_EXTRA = int(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_MAX_EXTRA", "6")) + + +def _pane_count(layout_payload: dict) -> int: + layout = layout_payload.get("layout") or {} + return len(layout.get("panes") or []) + + +def _largest_split_frame(layout_payload: dict) -> dict: + selected = layout_payload.get("selectedPanels") or [] + best = None + best_area = -1.0 + for row in selected: + for split in row.get("splitViews") or []: + frame = split.get("frame") + if not frame: + continue + try: + x = float(frame.get("x", 0.0)) + y = float(frame.get("y", 0.0)) + width = float(frame.get("width", 0.0)) + height = float(frame.get("height", 0.0)) + except (TypeError, ValueError): + continue + if width <= 0.0 or height <= 0.0: + continue + area = width * height + if area > best_area: + best_area = area + best = {"x": x, "y": y, "width": width, "height": height} + if best is None: + raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}") + return best + + +def _container_frame(layout_payload: dict) -> dict: + container = (layout_payload.get("layout") or {}).get("containerFrame") + if container: + try: + return { + "x": float(container.get("x", 0.0)), + "y": float(container.get("y", 0.0)), + "width": float(container.get("width", 0.0)), + "height": float(container.get("height", 0.0)), + } + except (TypeError, ValueError): + pass + return _largest_split_frame(layout_payload) + + +def _pane_frames_sorted_x(layout_payload: dict) -> list[dict]: + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + frames: list[dict] = [] + for pane in panes: + frame = pane.get("frame") or {} + try: + frames.append( + { + "pane_id": str(pane.get("paneId") or ""), + "x": float(frame.get("x", 0.0)), + "y": float(frame.get("y", 0.0)), + "width": float(frame.get("width", 0.0)), + "height": float(frame.get("height", 0.0)), + } + ) + except (TypeError, ValueError): + continue + return sorted(frames, key=lambda p: (p["x"], p["y"])) + + +def _assert_same_frame( + *, + current: dict, + baseline: dict, + workspace_index: int, + cycle: int, + phase: str, + sample: int, + trace: list[str], +) -> None: + deltas = { + key: abs(float(current[key]) - float(baseline[key])) + for key in ("x", "y", "width", "height") + } + shifted = {k: v for k, v in deltas.items() if v > EPSILON} + if shifted: + raise cmuxError( + "Container frame shifted " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sample={sample}, " + f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON}); " + f"recent_actions={trace}" + ) + + +def _assert_two_panes_left_right(layout_payload: dict, *, workspace_index: int, cycle: int, trace: list[str]) -> None: + panes = _pane_frames_sorted_x(layout_payload) + if len(panes) != 2: + raise cmuxError( + f"Expected exactly 2 panes in two-pane phase, got {len(panes)} " + f"(workspace={workspace_index}, cycle={cycle}); panes={panes}; recent_actions={trace}" + ) + + left, right = panes[0], panes[1] + if left["width"] <= 0.0 or left["height"] <= 0.0 or right["width"] <= 0.0 or right["height"] <= 0.0: + raise cmuxError( + f"Collapsed pane in two-pane phase (workspace={workspace_index}, cycle={cycle}): " + f"left={left} right={right}; recent_actions={trace}" + ) + + if left["x"] >= right["x"]: + raise cmuxError( + f"Two-pane geometry is not left/right (workspace={workspace_index}, cycle={cycle}): " + f"left={left} right={right}; recent_actions={trace}" + ) + + +def _selected_panel_by_pane(layout_payload: dict) -> dict[str, str]: + out: dict[str, str] = {} + for row in layout_payload.get("selectedPanels") or []: + pane_id = str(row.get("paneId") or "") + panel_id = str(row.get("panelId") or "") + if pane_id and panel_id: + out[pane_id] = panel_id + return out + + +def _rightmost_pane_id(layout_payload: dict) -> str: + panes = _pane_frames_sorted_x(layout_payload) + if len(panes) < 2: + raise cmuxError(f"Expected at least 2 panes to resolve rightmost pane: {panes}") + pane_id = str(panes[-1].get("pane_id") or "") + if not pane_id: + raise cmuxError(f"Rightmost pane is missing pane_id: {panes[-1]}") + return pane_id + + +def _rightmost_panel_id(layout_payload: dict) -> str: + pane_id = _rightmost_pane_id(layout_payload) + selected = _selected_panel_by_pane(layout_payload) + panel_id = str(selected.get(pane_id) or "") + if not panel_id: + raise cmuxError(f"Missing selected panel for rightmost pane: pane_id={pane_id}, selected={selected}") + return panel_id + + +def _safe_layout_debug(c: cmux, *, timeout_state: dict[str, int], context: str) -> dict: + for attempt in range(0, max(0, LAYOUT_TIMEOUT_RETRIES) + 1): + try: + return c.layout_debug() + except cmuxError as exc: + if "timed out waiting for response" not in str(exc).lower(): + raise + + timeout_state["count"] = timeout_state.get("count", 0) + 1 + count = timeout_state["count"] + if count > max(0, MAX_LAYOUT_TIMEOUTS): + raise cmuxError( + f"Exceeded layout_debug timeout budget (count={count}, max={MAX_LAYOUT_TIMEOUTS}, context={context})" + ) from exc + + if attempt >= max(0, LAYOUT_TIMEOUT_RETRIES): + raise cmuxError( + f"layout_debug timed out after retries (attempts={attempt + 1}, count={count}, context={context})" + ) from exc + + if LAYOUT_TIMEOUT_RETRY_SLEEP_S > 0: + time.sleep(LAYOUT_TIMEOUT_RETRY_SLEEP_S) + + raise cmuxError(f"layout_debug retry loop exhausted unexpectedly (context={context})") + + +def _sample_while( + c: cmux, + *, + baseline: dict, + deadline: float, + workspace_index: int, + cycle: int, + phase: str, + trace: list[str], + timeout_state: dict[str, int], +) -> int: + sampled = 0 + while time.time() < deadline: + payload = _safe_layout_debug( + c, + timeout_state=timeout_state, + context=f"sample workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}", + ) + current = _container_frame(payload) + _assert_same_frame( + current=current, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase=phase, + sample=sampled, + trace=trace, + ) + + panes_now = _pane_count(payload) + if panes_now > 2: + raise cmuxError( + f"Observed >2 panes in strict two-pane fuzz " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); " + f"recent_actions={trace}" + ) + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + return sampled + + +def _wait_for_panes( + c: cmux, + *, + target_panes: int, + baseline: dict, + workspace_index: int, + cycle: int, + phase: str, + timeout_s: float, + trace: list[str], + timeout_state: dict[str, int], +) -> tuple[dict, int]: + deadline = time.time() + timeout_s + sampled = 0 + last = None + + while time.time() < deadline: + payload = _safe_layout_debug( + c, + timeout_state=timeout_state, + context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}", + ) + last = payload + current = _container_frame(payload) + _assert_same_frame( + current=current, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase=phase, + sample=sampled, + trace=trace, + ) + + panes_now = _pane_count(payload) + if panes_now > 2: + raise cmuxError( + f"Observed >2 panes in strict two-pane fuzz while waiting " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); " + f"recent_actions={trace}" + ) + if panes_now == target_panes: + return payload, sampled + 1 + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + + raise cmuxError( + f"Timed out waiting for {target_panes} panes " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, " + f"last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); recent_actions={trace}" + ) + + +def _wait_for_single_pane_after_ctrl_d( + c: cmux, + *, + baseline: dict, + workspace_index: int, + cycle: int, + phase: str, + timeout_s: float, + recent_actions: deque[str], + timeout_state: dict[str, int], +) -> tuple[dict, int, int]: + deadline = time.time() + timeout_s + sampled = 0 + extra_ctrl_d = 0 + last = None + next_retry_at = time.time() + max(0.0, CTRL_D_RETRY_INTERVAL_S) + + while time.time() < deadline: + payload = _safe_layout_debug( + c, + timeout_state=timeout_state, + context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}", + ) + last = payload + current = _container_frame(payload) + trace = list(recent_actions) + _assert_same_frame( + current=current, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase=phase, + sample=sampled, + trace=trace, + ) + + panes_now = _pane_count(payload) + if panes_now > 2: + raise cmuxError( + f"Observed >2 panes in strict two-pane fuzz while waiting " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); " + f"recent_actions={trace}" + ) + if panes_now == 1: + return payload, sampled + 1, extra_ctrl_d + + now = time.time() + if panes_now == 2 and extra_ctrl_d < max(0, CTRL_D_MAX_EXTRA) and now >= next_retry_at: + retry_right_panel_id = _rightmost_panel_id(payload) + try: + c.send_key_surface(retry_right_panel_id, "ctrl-d") + except cmuxError as exc: + # Pane/surface can disappear between layout sample and send call under heavy churn. + # Skip this retry tick and re-sample. + if "not_found" in str(exc).lower(): + next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S) + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + continue + raise + extra_ctrl_d += 1 + recent_actions.append( + f"ws={workspace_index} cycle={cycle} action=ctrl+d(extra:{extra_ctrl_d}/{CTRL_D_MAX_EXTRA},surface={retry_right_panel_id})" + ) + next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S) + + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + + raise cmuxError( + f"Timed out waiting for 1 pane after ctrl+d " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, " + f"extra_ctrl_d={extra_ctrl_d}, last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); " + f"recent_actions={list(recent_actions)}" + ) + + +def main() -> int: + rng = random.Random(FUZZ_SEED) + recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL)) + total_samples = 0 + total_cycles = 0 + total_extra_ctrl_d = 0 + timeout_state: dict[str, int] = {"count": 0} + + with cmux(SOCKET_PATH) as c: + c.activate_app() + + for workspace_index in range(1, WORKSPACES + 1): + ws = c.new_workspace() + c.select_workspace(ws) + c.activate_app() + time.sleep(0.08) + + start = _safe_layout_debug(c, timeout_state=timeout_state, context=f"workspace={workspace_index} start") + baseline = _container_frame(start) + start_panes = _pane_count(start) + if start_panes != 1: + raise cmuxError(f"New workspace did not start as single pane (workspace={workspace_index}, panes={start_panes})") + + for cycle in range(1, CYCLES_PER_WORKSPACE + 1): + total_cycles += 1 + + if PRE_ACTION_JITTER_MAX_S > 0: + time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S)) + + recent_actions.append(f"ws={workspace_index} cycle={cycle} action=cmd+d") + c.simulate_shortcut("cmd+d") + + after_split, sampled = _wait_for_panes( + c, + target_panes=2, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase="after_cmd+d", + timeout_s=TRANSITION_TIMEOUT_S, + trace=list(recent_actions), + timeout_state=timeout_state, + ) + total_samples += sampled + _assert_two_panes_left_right(after_split, workspace_index=workspace_index, cycle=cycle, trace=list(recent_actions)) + + hold_split = rng.uniform(HOLD_MIN_S, HOLD_MAX_S) + total_samples += _sample_while( + c, + baseline=baseline, + deadline=time.time() + hold_split, + workspace_index=workspace_index, + cycle=cycle, + phase="hold_2pane", + trace=list(recent_actions), + timeout_state=timeout_state, + ) + + if PRE_ACTION_JITTER_MAX_S > 0: + time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S)) + + right_panel_id = _rightmost_panel_id(after_split) + recent_actions.append(f"ws={workspace_index} cycle={cycle} action=ctrl+d(surface={right_panel_id})") + c.send_key_surface(right_panel_id, "ctrl-d") + + _, sampled, extra_ctrl_d = _wait_for_single_pane_after_ctrl_d( + c, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase="after_ctrl+d", + timeout_s=TRANSITION_TIMEOUT_S, + recent_actions=recent_actions, + timeout_state=timeout_state, + ) + total_samples += sampled + total_extra_ctrl_d += extra_ctrl_d + + hold_single = rng.uniform(HOLD_MIN_S, HOLD_MAX_S) + total_samples += _sample_while( + c, + baseline=baseline, + deadline=time.time() + hold_single, + workspace_index=workspace_index, + cycle=cycle, + phase="hold_1pane", + trace=list(recent_actions), + timeout_state=timeout_state, + ) + + c.close_workspace(ws) + time.sleep(0.05) + + print( + "PASS: strict two-pane cmd+d/ctrl+d frame guard " + f"(seed={FUZZ_SEED}, workspaces={WORKSPACES}, cycles={total_cycles}, samples={total_samples}, " + f"extra_ctrl_d={total_extra_ctrl_d}, epsilon={EPSILON}, layout_timeouts={timeout_state.get('count', 0)})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 561f052fdd26f81338feefe704fdd1f9da63ea23 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:31:24 -0800 Subject: [PATCH 051/136] Use theme background for browser omnibar chrome in light mode --- Sources/Panels/BrowserPanelView.swift | 24 ++++++++----- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ac19b086..f3e7e861 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -166,6 +166,18 @@ private extension View { } } +func resolvedBrowserChromeBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + switch colorScheme { + case .dark, .light: + return themeBackgroundColor + @unknown default: + return themeBackgroundColor + } +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -239,14 +251,10 @@ struct BrowserPanelView: View { } private var browserChromeBackgroundColor: NSColor { - switch colorScheme { - case .dark: - return GhosttyApp.shared.defaultBackgroundColor - case .light: - return .windowBackgroundColor - @unknown default: - return .windowBackgroundColor - } + resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + ) } var body: some View { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ba914a50..e7205bb1 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -655,6 +655,40 @@ final class BrowserThemeSettingsTests: XCTestCase { } } +final class BrowserPanelChromeBackgroundColorTests: XCTestCase { + func testLightModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .light) + } + + func testDarkModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .dark) + } + + private func assertResolvedColorMatchesTheme( + for colorScheme: ColorScheme, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0) + + guard + let actual = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expected = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) + } +} + final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut From 0eef387d5dfab57a2c598c4ec14715a7582459c9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:55:01 -0800 Subject: [PATCH 052/136] Tint browser omnibar pill with theme accent --- Sources/Panels/BrowserPanelView.swift | 28 ++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f3e7e861..abe6975d 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -178,6 +178,24 @@ func resolvedBrowserChromeBackgroundColor( } } +func resolvedBrowserOmnibarPillBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor, + accentColor: NSColor +) -> NSColor { + let accentMix: CGFloat + switch colorScheme { + case .light: + accentMix = 0.08 + case .dark: + accentMix = 0.12 + @unknown default: + accentMix = 0.08 + } + + return themeBackgroundColor.blended(withFraction: accentMix, of: accentColor) ?? themeBackgroundColor +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -257,6 +275,14 @@ struct BrowserPanelView: View { ) } + private var omnibarPillBackgroundColor: NSColor { + resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: browserChromeBackgroundColor, + accentColor: .controlAccentColor + ) + } + var body: some View { VStack(spacing: 0) { addressBar @@ -656,7 +682,7 @@ struct BrowserPanelView: View { .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) + .fill(Color(nsColor: omnibarPillBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e7205bb1..201942d3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -689,6 +689,46 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { } } +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeUsesSubtleAccentTintOverThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.08) + } + + func testDarkModeUsesSlightlyStrongerAccentTintOverThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.12) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + accentMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let accent = NSColor(srgbRed: 0.25, green: 0.47, blue: 0.92, alpha: 1.0) + let expected = themeBackground.blended(withFraction: accentMix, of: accent) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground, + accentColor: accent + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} + final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut From 41b24b788316c4cc154a6248636f3e476bdf0a92 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:01:35 -0800 Subject: [PATCH 053/136] Guard split shortcuts during transient focus fallback --- Sources/AppDelegate.swift | 60 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 46 ++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f6811ae8..6d789500 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -183,6 +183,17 @@ func browserZoomShortcutAction( return nil } +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow +} + func shouldRouteTerminalFontZoomShortcutToGhostty( firstResponderIsGhostty: Bool, flags: NSEvent.ModifierFlags, @@ -2425,11 +2436,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Split actions: Cmd+D / Cmd+Shift+D if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } _ = performSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } _ = performSplitShortcut(direction: .down) return true } @@ -2564,6 +2581,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false + } + + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } + + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true + } + #if DEBUG private func logBrowserZoomShortcutTrace( stage: String, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 92e0fcd0..682e33c8 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -54,6 +54,52 @@ private func installCmuxUnitTestInspectorOverride() { cmuxUnitTestInspectorOverrideInstalled = true } +final class SplitShortcutTransientFocusGuardTests: XCTestCase { + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: false + ) + ) + } + + func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: false, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false From 0d03b58be8f58c2870e4e19b4e85aac2e65bc390 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:02:19 -0800 Subject: [PATCH 054/136] Tune omnibar pill tint toward theme background --- Sources/Panels/BrowserPanelView.swift | 6 +++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index abe6975d..7aa2a29b 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -186,11 +186,11 @@ func resolvedBrowserOmnibarPillBackgroundColor( let accentMix: CGFloat switch colorScheme { case .light: - accentMix = 0.08 + accentMix = 0.02 case .dark: - accentMix = 0.12 + accentMix = 0.03 @unknown default: - accentMix = 0.08 + accentMix = 0.02 } return themeBackgroundColor.blended(withFraction: accentMix, of: accentColor) ?? themeBackgroundColor diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 201942d3..0bce884b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -691,11 +691,11 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { func testLightModeUsesSubtleAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.08) + assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.02) } func testDarkModeUsesSlightlyStrongerAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.12) + assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.03) } private func assertResolvedColorMatchesExpectedBlend( From 0d98db72774eb58625c824df7b4e4299f21b5100 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:03:55 -0800 Subject: [PATCH 055/136] Fix titlebar text dragging while preserving folder icon drag --- Sources/AppDelegate.swift | 66 +++++ Sources/ContentView.swift | 137 +++++++++- Sources/WindowDragHandleView.swift | 215 ++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 253 ++++++++++++++++++ 4 files changed, 656 insertions(+), 15 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7bafbc8e..936f5475 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -193,6 +193,29 @@ func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) } #endif +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true + } + candidate = view.superview + } + return false +} + +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? @@ -288,6 +311,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.cmux_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -787,6 +820,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false + window.isMovable = false window.center() window.contentView = NSHostingView(rootView: root) @@ -1546,11 +1580,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent static func installWindowResponderSwizzlesForTesting() { _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle } private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle } private func installShortcutMonitor() { @@ -3869,6 +3905,36 @@ private extension NSWindow { return cmux_makeFirstResponder(responder) } + @objc func cmux_sendEvent(_ event: NSEvent) { + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), + let contentView = self.contentView else { + cmux_sendEvent(event) + return + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + cmux_sendEvent(event) + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7a8cb326..0d216936 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1211,6 +1211,7 @@ struct ContentView: View { WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) + .allowsHitTesting(false) HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { @@ -1226,6 +1227,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .bold)) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) + .allowsHitTesting(false) Spacer() @@ -1238,9 +1240,6 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) - .onTapGesture(count: 2) { - NSApp.keyWindow?.zoom(nil) - } .background(fakeTitlebarBackground) .overlay(alignment: .bottom) { Rectangle() @@ -1540,6 +1539,9 @@ struct ContentView: View { // Do not make the entire background draggable; it interferes with drag gestures // like sidebar tab reordering in multi-window mode. window.isMovableByWindowBackground = false + // Keep the window immovable by default so titlebar controls (like the folder icon) + // cannot accidentally initiate native window drags. + window.isMovable = false window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications @@ -4042,9 +4044,21 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable { } } -private final class DraggableFolderNSView: NSView, NSDraggingSource { +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + var directory: String - private var imageView: NSImageView! + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } init(directory: String) { self.directory = directory @@ -4060,8 +4074,10 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { NSSize(width: 16, height: 16) } + override var mouseDownCanMoveWindow: Bool { false } + private func setupImageView() { - imageView = NSImageView() + imageView = FolderIconImageView() imageView.imageScaling = .scaleProportionallyDown imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) @@ -4086,9 +4102,40 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { return context == .outsideApplication ? [.copy, .link] : .copy } - override func mouseDown(with event: NSEvent) { + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() #if DEBUG - dlog("folder.dragStart dir=\(directory)") + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + maybeDisableWindowDraggingEarly(trigger: "hitTest") + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") #endif let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) @@ -4097,7 +4144,19 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) - beginDraggingSession(with: [draggingItem], event: event, source: self) + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + if !hasActiveDragSession { + restoreWindowMovableStateIfNeeded() + } } override func rightMouseDown(with event: NSEvent) { @@ -4166,6 +4225,59 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { // Open "Computer" view in Finder (shows all volumes) NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState } /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested @@ -4247,11 +4359,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { /// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + private struct TitlebarLeadingInsetReader: NSViewRepresentable { @Binding var inset: CGFloat func makeNSView(context: Context) -> NSView { - let view = NSView() + let view = TitlebarLeadingInsetPassthroughView() view.setFrameSize(.zero) return view } diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index da9127e4..ebc62a05 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,23 +1,184 @@ import AppKit +import Bonsplit import SwiftUI +private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) +} + +private var windowDragSuppressionDepthKey: UInt8 = 0 + +func beginWindowDragSuppression(window: NSWindow?) -> Int? { + guard let window else { return nil } + let current = windowDragSuppressionDepth(window: window) + let next = current + 1 + objc_setAssociatedObject( + window, + &windowDragSuppressionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return next +} + +@discardableResult +func endWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + let current = windowDragSuppressionDepth(window: window) + let next = max(0, current - 1) + if next == 0 { + objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } else { + objc_setAssociatedObject( + window, + &windowDragSuppressionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + return next +} + +func windowDragSuppressionDepth(window: NSWindow?) -> Int { + guard let window, + let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else { + return 0 + } + return value.intValue +} + +func isWindowDragSuppressed(window: NSWindow?) -> Bool { + windowDragSuppressionDepth(window: window) > 0 +} + +/// Temporarily enables window movability for explicit drag-handle drags, then +/// restores the previous movability state after `body` finishes. +@discardableResult +func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? { + guard let window else { + body() + return nil + } + + let previousMovableState = window.isMovable + if !previousMovableState { + window.isMovable = true + } + defer { + if window.isMovable != previousMovableState { + window.isMovable = previousMovableState + } + } + + body() + return previousMovableState +} + +private enum WindowDragHandleHitTestState { + static var isResolvingTopHit = false +} + +/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty +/// titlebar space. Treat those as pass-through so explicit sibling checks decide. +func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { + let className = String(describing: type(of: view)) + if className.contains("HostContainerView") + || className.contains("AppKitWindowHostingView") + || className.contains("NSHostingView") { + return true + } + if let window = view.window, view === window.contentView { + return true + } + return false +} + /// Returns whether the titlebar drag handle should capture a hit at `point`. /// We only claim the hit when no sibling view already handles it, so interactive /// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool { - guard dragHandleView.bounds.contains(point) else { return false } - guard let superview = dragHandleView.superview else { return true } + if isWindowDragSuppressed(window: dragHandleView.window) { + #if DEBUG + let depth = windowDragSuppressionDepth(window: dragHandleView.window) + dlog( + "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + return false + } + + guard dragHandleView.bounds.contains(point) else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") + #endif + return false + } + + guard let superview = dragHandleView.superview else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") + #endif + return true + } + + if let window = dragHandleView.window, + let contentView = window.contentView, + !WindowDragHandleHitTestState.isResolvingTopHit { + let pointInWindow = dragHandleView.convert(point, to: nil) + let pointInContent = contentView.convert(pointInWindow, from: nil) + + WindowDragHandleHitTestState.isResolvingTopHit = true + let topHit = contentView.hitTest(pointInContent) + WindowDragHandleHitTestState.isResolvingTopHit = false + + if let topHit { + let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView) + let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) passiveHost=\(isPassiveHostHit)" + ) + #endif + if ownsTopHit { + return true + } + if !isPassiveHostHit { + return false + } + } + } + + #if DEBUG + let siblingCount = superview.subviews.count + #endif for sibling in superview.subviews.reversed() { guard sibling !== dragHandleView else { continue } guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } let pointInSibling = dragHandleView.convert(point, to: sibling) - if sibling.hitTest(pointInSibling) != nil { + if let hitView = sibling.hitTest(pointInSibling) { + let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) + if passiveHostHit { + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true" + ) + #endif + continue + } + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false" + ) + #endif return false } } + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") + #endif return true } @@ -34,9 +195,53 @@ struct WindowDragHandleView: NSViewRepresentable { } private final class DraggableView: NSView { - override var mouseDownCanMoveWindow: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { - windowDragHandleShouldCaptureHit(point, in: self) ? self : nil + let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)" + ) + #endif + return shouldCapture ? self : nil + } + + override func mouseDown(with event: NSEvent) { + #if DEBUG + let point = convert(event.locationInWindow, from: nil) + let depth = windowDragSuppressionDepth(window: window) + dlog( + "titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)" + ) + #endif + + if event.clickCount >= 2 { + window?.zoom(nil) + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick zoom=1") + #endif + return + } + + guard !isWindowDragSuppressed(window: window) else { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") + #endif + return + } + + if let window { + let previousMovableState = withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + #if DEBUG + let restored = previousMovableState.map { String($0) } ?? "nil" + dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") + #endif + } else { + super.mouseDown(with: event) + } } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a2fa303..f97bc015 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3824,6 +3824,14 @@ final class WindowDragHandleHitTests: XCTestCase { } } + private final class HostContainerView: NSView {} + private final class PassiveHostContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + return super.hitTest(point) ?? self + } + } + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) let dragHandle = NSView(frame: container.bounds) @@ -3869,6 +3877,251 @@ final class WindowDragHandleHitTests: XCTestCase { XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle)) } + + func testPassiveHostingTopHitClassification() { + XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero))) + XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero))) + } + + func testDragHandleIgnoresPassiveHostSiblingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + container.addSubview(passiveHost) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle), + "Passive host wrappers should not block titlebar drag capture" + ) + } + + func testDragHandleRespectsInteractiveChildInsidePassiveHost() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + passiveHost.addSubview(folderControl) + container.addSubview(passiveHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle), + "Interactive controls inside passive host wrappers should still receive hits" + ) + } +} + +@MainActor +final class DraggableFolderHitTests: XCTestCase { + func testFolderHitTestReturnsContainerWhenInsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else { + XCTFail("Expected folder icon to capture inside hit") + return + } + XCTAssertTrue(hit === folderView) + } + + func testFolderHitTestReturnsNilOutsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8))) + } + + func testFolderIconDisablesWindowMoveBehavior() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertFalse(folderView.mouseDownCanMoveWindow) + } +} + +@MainActor +final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { + func testLeadingInsetViewDoesNotParticipateInHitTesting() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) + } + + func testLeadingInsetViewCannotMoveWindowViaMouseDown() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertFalse(view.mouseDownCanMoveWindow) + } +} + +@MainActor +final class FolderWindowMoveSuppressionTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testSuppressionDisablesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, true) + XCTAssertFalse(window.isMovable) + } + + func testSuppressionPreservesAlreadyImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testRestoreAppliesPreviousMovableState() { + let window = makeWindow() + window.isMovable = false + + restoreWindowDragging(window: window, previousMovableState: true) + XCTAssertTrue(window.isMovable) + + restoreWindowDragging(window: window, previousMovableState: false) + XCTAssertFalse(window.isMovable) + } + + func testWindowDragSuppressionDepthLifecycle() { + let window = makeWindow() + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testWindowDragSuppressionIsReferenceCounted() { + let window = makeWindow() + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(beginWindowDragSuppression(window: window), 2) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 2) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testTemporaryWindowMovableEnableRestoresImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testTemporaryWindowMovableEnablePreservesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, true) + XCTAssertTrue(window.isMovable) + } +} + +@MainActor +final class WindowMoveSuppressionHitPathTests: XCTestCase { + private func makeWindowWithContentView() -> (NSWindow, NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + return (window, contentView) + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testSuppressionHitPathRecognizesFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView)) + } + + func testSuppressionHitPathRecognizesDescendantOfFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + let child = NSView(frame: .zero) + folderView.addSubview(child) + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child)) + } + + func testSuppressionHitPathIgnoresUnrelatedViews() { + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero))) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil)) + } + + func testSuppressionEventPathRecognizesFolderHitInsideWindow() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16) + contentView.addSubview(folderView) + + let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window) + + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event)) + } + + func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + contentView.addSubview(plainView) + + let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down)) + + let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) + } } @MainActor From cfce7e93e0e3f44843d5559549ecbce9bb890fbb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:08:46 -0800 Subject: [PATCH 056/136] Darken omnibar pill relative to theme background --- Sources/Panels/BrowserPanelView.swift | 16 +++++++--------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 16 +++++++--------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7aa2a29b..f91855dd 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -180,20 +180,19 @@ func resolvedBrowserChromeBackgroundColor( func resolvedBrowserOmnibarPillBackgroundColor( for colorScheme: ColorScheme, - themeBackgroundColor: NSColor, - accentColor: NSColor + themeBackgroundColor: NSColor ) -> NSColor { - let accentMix: CGFloat + let darkenMix: CGFloat switch colorScheme { case .light: - accentMix = 0.02 + darkenMix = 0.04 case .dark: - accentMix = 0.03 + darkenMix = 0.05 @unknown default: - accentMix = 0.02 + darkenMix = 0.04 } - return themeBackgroundColor.blended(withFraction: accentMix, of: accentColor) ?? themeBackgroundColor + return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor } /// View for rendering a browser panel with address bar @@ -278,8 +277,7 @@ struct BrowserPanelView: View { private var omnibarPillBackgroundColor: NSColor { resolvedBrowserOmnibarPillBackgroundColor( for: colorScheme, - themeBackgroundColor: browserChromeBackgroundColor, - accentColor: .controlAccentColor + themeBackgroundColor: browserChromeBackgroundColor ) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0bce884b..c3a0ef37 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -690,29 +690,27 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { } final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { - func testLightModeUsesSubtleAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.02) + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) } - func testDarkModeUsesSlightlyStrongerAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.03) + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) } private func assertResolvedColorMatchesExpectedBlend( for colorScheme: ColorScheme, - accentMix: CGFloat, + darkenMix: CGFloat, file: StaticString = #filePath, line: UInt = #line ) { let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) - let accent = NSColor(srgbRed: 0.25, green: 0.47, blue: 0.92, alpha: 1.0) - let expected = themeBackground.blended(withFraction: accentMix, of: accent) ?? themeBackground + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground guard let actual = resolvedBrowserOmnibarPillBackgroundColor( for: colorScheme, - themeBackgroundColor: themeBackground, - accentColor: accent + themeBackgroundColor: themeBackground ).usingColorSpace(.sRGB), let expectedSRGB = expected.usingColorSpace(.sRGB), let themeSRGB = themeBackground.usingColorSpace(.sRGB) From 53ef6a5f7dddb79b035de2e92d30f20fcea0c391 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:11:01 -0800 Subject: [PATCH 057/136] Upgrade Sentry: tracing, breadcrumbs, dSYM upload (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Upgrade Sentry: tracing, breadcrumbs, dSYM upload - Enhanced Sentry SDK init with performance tracing (10% sample), explicit app hang timeout, stack trace attachment, and HTTP failure capture - Added breadcrumbs for key user actions: workspace switch/create/close, split creation, command palette open/close, app focus — these give context to hang/crash reports - Added dSYM upload step to nightly and release CI workflows so hang stacks are fully symbolicated (requires SENTRY_AUTH_TOKEN secret) - Created SentryHelper.swift with lightweight breadcrumb helper Closes https://github.com/manaflow-ai/cmux/issues/365 * Remove command palette breadcrumbs Not useful for hang diagnosis — keep only workspace/tab/split/focus breadcrumbs that correlate with heavy operations. --- .github/workflows/nightly.yml | 13 +++++++++++++ .github/workflows/release.yml | 14 ++++++++++++++ GhosttyTabs.xcodeproj/project.pbxproj | 4 ++++ Sources/AppDelegate.swift | 12 ++++++++++++ Sources/SentryHelper.swift | 9 +++++++++ Sources/TabManager.swift | 6 ++++++ 6 files changed, 58 insertions(+) create mode 100644 Sources/SentryHelper.swift diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e200f251..a8ebeea4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -294,6 +294,19 @@ jobs: # by appcast URLs to prevent signature/asset mismatch races. cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE" + - name: Upload dSYMs to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: manaflow + SENTRY_PROJECT: cmuxterm-macos + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload" + exit 0 + fi + brew install getsentry/tools/sentry-cli || true + sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + - name: Generate Sparkle appcast (nightly) env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3176697b..9063de75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,6 +250,20 @@ jobs: xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + - name: Upload dSYMs to Sentry + if: steps.guard_release_assets.outputs.skip_all != 'true' + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: manaflow + SENTRY_PROJECT: cmuxterm-macos + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload" + exit 0 + fi + brew install getsentry/tools/sentry-cli || true + sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + - name: Generate Sparkle appcast if: steps.guard_release_assets.outputs.skip_all != 'true' env: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 58641e08..3448b298 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; @@ -146,6 +147,7 @@ A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; @@ -322,6 +324,7 @@ A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, + A5001600 /* SentryHelper.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -551,6 +554,7 @@ A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + A5001601 /* SentryHelper.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5fc94aa0..ef7215c0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -699,6 +699,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent options.debug = false #endif options.sendDefaultPii = true + + // Performance tracing (10% of transactions) + options.tracesSampleRate = 0.1 + // App hang timeout (default is 2s, be explicit) + options.appHangTimeoutInterval = 2.0 + // Attach stack traces to all events + options.attachStacktrace = true + // Capture failed HTTP requests + options.enableCaptureFailedRequests = true } if !isRunningUnderXCTest { @@ -804,6 +813,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif func applicationDidBecomeActive(_ notification: Notification) { + sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ + "tabCount": tabManager?.tabs.count ?? 0 + ]) let env = ProcessInfo.processInfo.environment if !isRunningUnderXCTest(env) { PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive") diff --git a/Sources/SentryHelper.swift b/Sources/SentryHelper.swift new file mode 100644 index 00000000..9877a46c --- /dev/null +++ b/Sources/SentryHelper.swift @@ -0,0 +1,9 @@ +import Sentry + +/// Add a Sentry breadcrumb for user-action context in hang/crash reports. +func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) { + let crumb = Breadcrumb(level: .info, category: category) + crumb.message = message + crumb.data = data + SentrySDK.addBreadcrumb(crumb) +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0e38e366..5a59b82a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -567,6 +567,9 @@ class TabManager: ObservableObject { @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } + sentryBreadcrumb("workspace.switch", data: [ + "tabCount": tabs.count + ]) let previousTabId = oldValue if let previousTabId, let previousPanelId = focusedPanelId(for: previousTabId) { @@ -752,6 +755,7 @@ class TabManager: ObservableObject { @discardableResult func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { + sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal @@ -963,6 +967,7 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } + sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) unwireClosedBrowserTracking(for: workspace) @@ -1725,6 +1730,7 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } + sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } From ed0dd1ccb71f7cfc8f3e7bc476baa5beb3b82da5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:39:58 -0800 Subject: [PATCH 058/136] Make omnibar suggestions popup/rows squircle --- Sources/Panels/BrowserPanelView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f91855dd..c98e913a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2627,10 +2627,10 @@ private struct OmnibarSuggestionsView: View { let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void - // Keep radii below the smallest rendered heights so corners don't get - // auto-clamped and visually change as popup height changes. - private let popupCornerRadius: CGFloat = 16 - private let rowHighlightCornerRadius: CGFloat = 12 + // Keep radii below half of the smallest rendered heights so this keeps a + // squircle silhouette instead of auto-clamping into a capsule. + private let popupCornerRadius: CGFloat = 12 + private let rowHighlightCornerRadius: CGFloat = 9 private let singleLineRowHeight: CGFloat = 24 private let rowSpacing: CGFloat = 1 private let topInset: CGFloat = 3 @@ -2802,8 +2802,9 @@ private struct OmnibarSuggestionsView: View { lineWidth: 1 ) ) + .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) - .contentShape(Rectangle()) + .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) .accessibilityIdentifier("BrowserOmnibarSuggestions") From b87d4fecda028ba96850b081ef5b0b711eafbd82 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:43:39 -0800 Subject: [PATCH 059/136] Move omnibar suggestions popover up by 2px --- Sources/Panels/BrowserPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c98e913a..5da2592a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -310,7 +310,7 @@ struct BrowserPanelView: View { } ) .frame(width: omnibarPillFrame.width) - .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6) + .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 4) .zIndex(1000) } } From 82ef5b8f6ee574035b2c1d0b99fcbf01d9f1a6cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:51:32 -0800 Subject: [PATCH 060/136] Move omnibar suggestions popover up 1px --- Sources/Panels/BrowserPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 5da2592a..88cbfb56 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -310,7 +310,7 @@ struct BrowserPanelView: View { } ) .frame(width: omnibarPillFrame.width) - .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 4) + .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3) .zIndex(1000) } } From 88c1dbc5d6af07a6dfc731d22f8a4f817ae37be9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:00:01 -0800 Subject: [PATCH 061/136] Fix omnibar focus thrash when another text field takes focus --- Sources/Panels/BrowserPanelView.swift | 37 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 29 +++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f91855dd..46f9147c 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2181,6 +2181,13 @@ struct OmnibarSuggestion: Identifiable, Hashable { } } +func browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: Bool, + nextResponderIsOtherTextField: Bool +) -> Bool { + suppressWebViewFocus && !nextResponderIsOtherTextField +} + private final class OmnibarNativeTextField: NSTextField { var onPointerDown: (() -> Void)? var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? @@ -2293,6 +2300,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } } + private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool { + guard let window, let field = parentField else { return false } + let responder = window.firstResponder + + if let editor = responder as? NSTextView, + let delegateField = editor.delegate as? NSTextField { + return delegateField !== field + } + + if let textField = responder as? NSTextField { + return textField !== field + } + + return false + } + + private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool { + return browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: parent.shouldSuppressWebViewFocus(), + nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window) + ) + } + func controlTextDidBeginEditing(_ obj: Notification) { if !parent.isFocused { DispatchQueue.main.async { @@ -2305,15 +2335,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { func controlTextDidEndEditing(_ obj: Notification) { if parent.isFocused { - if parent.shouldSuppressWebViewFocus() { + if shouldReacquireFocusAfterEndEditing(window: parentField?.window) { guard pendingFocusRequest != true else { return } pendingFocusRequest = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.pendingFocusRequest = nil guard self.parent.isFocused else { return } - guard self.parent.shouldSuppressWebViewFocus() else { return } guard let field = self.parentField, let window = field.window else { return } + guard self.shouldReacquireFocusAfterEndEditing(window: window) else { + self.parent.onFieldLostFocus() + return + } // Check both the field itself AND its field editor (which becomes // the actual first responder when the text field is being edited). let fr = window.firstResponder diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c875cf11..d74c082b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6012,3 +6012,32 @@ final class TerminalControllerSocketTextChunkTests: XCTestCase { ) } } + +final class BrowserOmnibarFocusPolicyTests: XCTestCase { + func testReacquiresFocusWhenWebViewSuppressionIsActiveAndNextResponderIsNotAnotherTextField() { + XCTAssertTrue( + browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: true, + nextResponderIsOtherTextField: false + ) + ) + } + + func testDoesNotReacquireFocusWhenAnotherTextFieldAlreadyTookFocus() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: true, + nextResponderIsOtherTextField: true + ) + ) + } + + func testDoesNotReacquireFocusWhenWebViewSuppressionIsInactive() { + XCTAssertFalse( + browserOmnibarShouldReacquireFocusAfterEndEditing( + suppressWebViewFocus: false, + nextResponderIsOtherTextField: false + ) + ) + } +} From 05101a1a104e3c544eb52d752330ce94f1ec0d09 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:06:50 -0800 Subject: [PATCH 062/136] Fix light theme omnibar suggestions popover styling --- Sources/Panels/BrowserPanelView.swift | 116 +++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 88cbfb56..f91b71e8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2626,6 +2626,7 @@ private struct OmnibarSuggestionsView: View { let searchSuggestionsEnabled: Bool let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void + @Environment(\.colorScheme) private var colorScheme // Keep radii below half of the smallest rendered heights so this keeps a // squircle silhouette instead of auto-clamping into a capsule. @@ -2683,6 +2684,101 @@ private struct OmnibarSuggestionsView: View { contentHeight > maxPopupHeight } + private var listTextColor: Color { + switch colorScheme { + case .light: + return Color(nsColor: .labelColor) + case .dark: + return Color.white.opacity(0.9) + @unknown default: + return Color(nsColor: .labelColor) + } + } + + private var badgeTextColor: Color { + switch colorScheme { + case .light: + return Color(nsColor: .secondaryLabelColor) + case .dark: + return Color.white.opacity(0.72) + @unknown default: + return Color(nsColor: .secondaryLabelColor) + } + } + + private var badgeBackgroundColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.06) + case .dark: + return Color.white.opacity(0.08) + @unknown default: + return Color.black.opacity(0.06) + } + } + + private var rowHighlightColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.07) + case .dark: + return Color.white.opacity(0.12) + @unknown default: + return Color.black.opacity(0.07) + } + } + + private var popupOverlayGradientColors: [Color] { + switch colorScheme { + case .light: + return [ + Color.white.opacity(0.55), + Color.white.opacity(0.2), + ] + case .dark: + return [ + Color.black.opacity(0.26), + Color.black.opacity(0.14), + ] + @unknown default: + return [ + Color.white.opacity(0.55), + Color.white.opacity(0.2), + ] + } + } + + private var popupBorderGradientColors: [Color] { + switch colorScheme { + case .light: + return [ + Color.white.opacity(0.65), + Color.black.opacity(0.12), + ] + case .dark: + return [ + Color.white.opacity(0.22), + Color.white.opacity(0.06), + ] + @unknown default: + return [ + Color.white.opacity(0.65), + Color.black.opacity(0.12), + ] + } + } + + private var popupShadowColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.18) + case .dark: + return Color.black.opacity(0.45) + @unknown default: + return Color.black.opacity(0.18) + } + } + @ViewBuilder private var rowsView: some View { VStack(spacing: rowSpacing) { @@ -2696,18 +2792,18 @@ private struct OmnibarSuggestionsView: View { HStack(spacing: 6) { Text(item.listText) .font(.system(size: 11)) - .foregroundStyle(Color.white.opacity(0.9)) + .foregroundStyle(listTextColor) .lineLimit(1) .truncationMode(.tail) if let badge = item.trailingBadgeText { Text(badge) .font(.system(size: 9.5, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.72)) + .foregroundStyle(badgeTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.white.opacity(0.08)) + .fill(badgeBackgroundColor) ) } Spacer(minLength: 0) @@ -2723,7 +2819,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( idx == selectedIndex - ? Color.white.opacity(0.12) + ? rowHighlightColor : Color.clear ) ) @@ -2778,10 +2874,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .fill( LinearGradient( - colors: [ - Color.black.opacity(0.26), - Color.black.opacity(0.14), - ], + colors: popupOverlayGradientColors, startPoint: .top, endPoint: .bottom ) @@ -2792,10 +2885,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .stroke( LinearGradient( - colors: [ - Color.white.opacity(0.22), - Color.white.opacity(0.06), - ], + colors: popupBorderGradientColors, startPoint: .top, endPoint: .bottom ), @@ -2803,7 +2893,7 @@ private struct OmnibarSuggestionsView: View { ) ) .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) - .shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) + .shadow(color: popupShadowColor, radius: 20, y: 10) .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) From 011abb62c9208170243c9f99f452baeb21975b48 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:10:02 -0800 Subject: [PATCH 063/136] Refine command palette focus restore and shortcut gating --- Sources/AppDelegate.swift | 54 ++++++++ Sources/ContentView.swift | 51 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 120 ++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ef7215c0..28c72ae1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -315,6 +315,42 @@ func commandPaletteSelectionDeltaForKeyboardNavigation( return nil } +func shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: Bool, + normalizedFlags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard isCommandPaletteVisible else { return false } + guard normalizedFlags.contains(.command) else { return false } + + let normalizedChars = chars.lowercased() + + if normalizedFlags == [.command] { + if normalizedChars == "a" + || normalizedChars == "c" + || normalizedChars == "v" + || normalizedChars == "x" + || normalizedChars == "z" + || normalizedChars == "y" { + return false + } + + switch keyCode { + case 51, 117, 123, 124: + return false + default: + break + } + } + + if normalizedFlags == [.command, .shift], normalizedChars == "z" { + return false + } + + return true +} + enum BrowserZoomShortcutAction: Equatable { case zoomIn case zoomOut @@ -2578,6 +2614,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: activeCommandPaletteWindow() != nil, + normalizedFlags: normalizedFlags, + chars: chars, + keyCode: event.keyCode + ) { + return true + } + if normalizedFlags == [.command], chars == "q" { return handleQuitShortcutWarning() } @@ -3100,6 +3145,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) } + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId + } + + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) + } + private func shouldBypassAppShortcutForFocusedBrowserAddressBar( flags: NSEvent.ModifierFlags, chars: String diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 55e8ec22..dd0b9520 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1169,9 +1169,15 @@ struct ContentView: View { } } + private enum CommandPaletteRestoreFocusIntent { + case panel + case browserAddressBar + } + private struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID + let intent: CommandPaletteRestoreFocusIntent } private enum CommandPaletteInputFocusTarget { @@ -4137,6 +4143,14 @@ struct ContentView: View { return false } + static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: Bool, + focusedBrowserAddressBarPanelId: UUID?, + focusedPanelId: UUID + ) -> Bool { + focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId + } + private func syncCommandPaletteDebugStateForObservedWindow() { guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) @@ -4178,9 +4192,15 @@ struct ContentView: View { private func presentCommandPalette(initialQuery: String) { if let panelContext = focusedPanelContext { + let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: panelContext.panel.panelType == .browser, + focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(), + focusedPanelId: panelContext.panelId + ) commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget( workspaceId: panelContext.workspace.id, - panelId: panelContext.panelId + panelId: panelContext.panelId, + intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel ) } else { commandPaletteRestoreFocusTarget = nil @@ -4236,18 +4256,47 @@ struct ContentView: View { } tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6) + return + } + guard attemptsRemaining > 0 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { guard !isCommandPalettePresented else { return } if let context = focusedPanelContext, context.workspace.id == target.workspaceId, context.panelId == target.panelId { + restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6) return } restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) } } + private func restoreCommandPaletteInputFocusIfNeeded( + target: CommandPaletteRestoreFocusTarget, + attemptsRemaining: Int + ) { + guard !isCommandPalettePresented else { return } + guard target.intent == .browserAddressBar else { return } + guard attemptsRemaining > 0 else { return } + guard let appDelegate = AppDelegate.shared else { return } + + if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + restoreCommandPaletteInputFocusIfNeeded( + target: target, + attemptsRemaining: attemptsRemaining - 1 + ) + } + } + private func resetCommandPaletteSearchFocus() { applyCommandPaletteInputFocusPolicy(.search) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d74c082b..3239d633 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1581,6 +1581,126 @@ final class CommandPaletteKeyboardNavigationTests: XCTestCase { } } +final class CommandPaletteOpenShortcutConsumptionTests: XCTestCase { + func testDoesNotConsumeWhenPaletteIsNotVisible() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: false, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + } + + func testConsumesAppCommandShortcutsWhenPaletteIsVisible() { + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "t", + keyCode: 17 + ) + ) + XCTAssertTrue( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: ",", + keyCode: 43 + ) + ) + } + + func testAllowsClipboardAndUndoShortcutsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "v", + keyCode: 9 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "z", + keyCode: 6 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command, .shift], + chars: "z", + keyCode: 6 + ) + ) + } + + func testAllowsArrowAndDeleteEditingCommandsForPaletteTextEditing() { + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 123 + ) + ) + XCTAssertFalse( + shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: true, + normalizedFlags: [.command], + chars: "", + keyCode: 51 + ) + ) + } +} + +final class CommandPaletteRestoreFocusStateMachineTests: XCTestCase { + func testRestoresBrowserAddressBarWhenPaletteOpenedFromFocusedAddressBar() { + let panelId = UUID() + XCTAssertTrue( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenFocusedPanelIsNotBrowser() { + let panelId = UUID() + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: false, + focusedBrowserAddressBarPanelId: panelId, + focusedPanelId: panelId + ) + ) + } + + func testDoesNotRestoreBrowserAddressBarWhenAnotherPanelHadAddressBarFocus() { + XCTAssertFalse( + ContentView.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: true, + focusedBrowserAddressBarPanelId: UUID(), + focusedPanelId: UUID() + ) + ) + } +} + final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" From 0dee87826816f8a1a28ceb86a04d972e5168f407 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:14:32 -0800 Subject: [PATCH 064/136] Fix light-mode sidebar typing indicator contrast --- Sources/ContentView.swift | 26 +++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 32 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index dd0b9520..7a6c5d8d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -28,6 +28,19 @@ private func coloredCircleImage(color: NSColor) -> NSImage { return image } +func sidebarStatusPillActiveForegroundNSColor( + colorScheme: ColorScheme, + opacity: CGFloat +) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + switch colorScheme { + case .dark: + return NSColor.white.withAlphaComponent(clampedOpacity) + default: + return NSColor.black.withAlphaComponent(clampedOpacity) + } +} + struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -6768,12 +6781,13 @@ private struct SidebarStatusPillsRow: View { let onFocus: () -> Void @State private var isExpanded: Bool = false + @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(alignment: .leading, spacing: 2) { Text(statusText) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(isActive ? activePrimaryTextColor : .secondary) .lineLimit(isExpanded ? nil : 3) .truncationMode(.tail) .multilineTextAlignment(.leading) @@ -6796,13 +6810,21 @@ private struct SidebarStatusPillsRow: View { } .buttonStyle(.plain) .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } } .help(statusText) } + private var activePrimaryTextColor: Color { + Color(nsColor: sidebarStatusPillActiveForegroundNSColor(colorScheme: colorScheme, opacity: 0.8)) + } + + private var activeSecondaryTextColor: Color { + Color(nsColor: sidebarStatusPillActiveForegroundNSColor(colorScheme: colorScheme, opacity: 0.65)) + } + private var statusText: String { entries .map { entry in diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3239d633..99a27709 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -773,6 +773,38 @@ final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { } } +final class SidebarStatusPillActiveForegroundColorTests: XCTestCase { + func testLightModeUsesBlackWithRequestedOpacity() { + guard let color = sidebarStatusPillActiveForegroundNSColor( + colorScheme: .light, + opacity: 0.8 + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) + } + + func testDarkModeUsesWhiteWithRequestedOpacity() { + guard let color = sidebarStatusPillActiveForegroundNSColor( + colorScheme: .dark, + opacity: 0.65 + ).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} + final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut From e9de2fa2ea32b4af74f2177787c6920eed024eb9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:18:43 -0800 Subject: [PATCH 065/136] Add star history chart to README --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index e2c10ae0..9122c289 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,16 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. +## Star History + + + + + + Star History Chart + + + ## Community - [Discord](https://discord.gg/xsgFEVrWCZ) From 666ef75d1eaff174dbd22d42160d2762bfaa3c48 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:22:39 -0800 Subject: [PATCH 066/136] Fix light-mode typing indicator contrast in active sidebar --- Sources/ContentView.swift | 41 ++++++++++--------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 24 ++++++----- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7a6c5d8d..a57755cf 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -28,17 +28,14 @@ private func coloredCircleImage(color: NSColor) -> NSImage { return image } -func sidebarStatusPillActiveForegroundNSColor( - colorScheme: ColorScheme, - opacity: CGFloat +func sidebarActiveForegroundNSColor( + opacity: CGFloat, + appAppearance: NSAppearance? = NSApp?.effectiveAppearance ) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) - switch colorScheme { - case .dark: - return NSColor.white.withAlphaComponent(clampedOpacity) - default: - return NSColor.black.withAlphaComponent(clampedOpacity) - } + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let baseColor: NSColor = (bestMatch == .darkAqua) ? .white : .black + return baseColor.withAlphaComponent(clampedOpacity) } struct ShortcutHintPillBackground: View { @@ -5929,11 +5926,13 @@ private struct TabItemView: View { } private var activePrimaryTextColor: Color { - usesInvertedActiveForeground ? .white : .primary + usesInvertedActiveForeground ? Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0)) : .primary } private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { - usesInvertedActiveForeground ? .white.opacity(opacity) : .secondary + usesInvertedActiveForeground + ? Color(nsColor: sidebarActiveForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary } private var activeUnreadBadgeFillColor: Color { @@ -6676,11 +6675,16 @@ private struct TabItemView: View { private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { if isActive { switch level { - case .info: return .white.opacity(0.5) - case .progress: return .white.opacity(0.8) - case .success: return .white.opacity(0.9) - case .warning: return .white.opacity(0.9) - case .error: return .white.opacity(0.9) + case .info: + return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.5)) + case .progress: + return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.8)) + case .success: + return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.9)) + case .warning: + return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.9)) + case .error: + return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.9)) } } switch level { @@ -6781,7 +6785,6 @@ private struct SidebarStatusPillsRow: View { let onFocus: () -> Void @State private var isExpanded: Bool = false - @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(alignment: .leading, spacing: 2) { @@ -6818,11 +6821,11 @@ private struct SidebarStatusPillsRow: View { } private var activePrimaryTextColor: Color { - Color(nsColor: sidebarStatusPillActiveForegroundNSColor(colorScheme: colorScheme, opacity: 0.8)) + Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.8)) } private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarStatusPillActiveForegroundNSColor(colorScheme: colorScheme, opacity: 0.65)) + Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.65)) } private var statusText: String { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 99a27709..8484c957 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -773,12 +773,13 @@ final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { } } -final class SidebarStatusPillActiveForegroundColorTests: XCTestCase { - func testLightModeUsesBlackWithRequestedOpacity() { - guard let color = sidebarStatusPillActiveForegroundNSColor( - colorScheme: .light, - opacity: 0.8 - ).usingColorSpace(.sRGB) else { +final class SidebarActiveForegroundColorTests: XCTestCase { + func testLightAppearanceUsesBlackWithRequestedOpacity() { + guard let lightAppearance = NSAppearance(named: .aqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.8, + appAppearance: lightAppearance + ).usingColorSpace(.sRGB) else { XCTFail("Expected sRGB-convertible color") return } @@ -789,11 +790,12 @@ final class SidebarStatusPillActiveForegroundColorTests: XCTestCase { XCTAssertEqual(color.alphaComponent, 0.8, accuracy: 0.001) } - func testDarkModeUsesWhiteWithRequestedOpacity() { - guard let color = sidebarStatusPillActiveForegroundNSColor( - colorScheme: .dark, - opacity: 0.65 - ).usingColorSpace(.sRGB) else { + func testDarkAppearanceUsesWhiteWithRequestedOpacity() { + guard let darkAppearance = NSAppearance(named: .darkAqua), + let color = sidebarActiveForegroundNSColor( + opacity: 0.65, + appAppearance: darkAppearance + ).usingColorSpace(.sRGB) else { XCTFail("Expected sRGB-convertible color") return } From 78d1a43733f6c81d6412ac066ee1a36961fb0b6f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:27:32 -0800 Subject: [PATCH 067/136] Persist workspace tab colors across session restore --- GhosttyTabs.xcodeproj/project.pbxproj | 8 +++--- Sources/SessionPersistence.swift | 1 + Sources/Workspace.swift | 8 +++++- cmuxTests/SessionPersistenceTests.swift | 33 +++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 0b100e14..7a798706 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; - A5001600 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001601 /* SessionPersistence.swift */; }; + A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; @@ -184,7 +184,7 @@ A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = ""; }; - A5001601 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; + A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -357,7 +357,7 @@ A5001219 /* WindowToolbarController.swift */, A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, - A5001601 /* SessionPersistence.swift */, + A5001611 /* SessionPersistence.swift */, ); path = Sources; sourceTree = ""; @@ -590,7 +590,7 @@ A5001209 /* WindowToolbarController.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, - A5001600 /* SessionPersistence.swift in Sources */, + A5001610 /* SessionPersistence.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f4459a68..d660d467 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -322,6 +322,7 @@ indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { struct SessionWorkspaceSnapshot: Codable, Sendable { var processTitle: String var customTitle: String? + var customColor: String? var isPinned: Bool var currentDirectory: String var focusedPanelId: UUID? diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a9b8021c..1fda66ce 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -118,6 +118,7 @@ extension Workspace { return SessionWorkspaceSnapshot( processTitle: processTitle, customTitle: customTitle, + customColor: customColor, isPinned: isPinned, currentDirectory: currentDirectory, focusedPanelId: focusedPanelId, @@ -156,6 +157,7 @@ extension Workspace { applyProcessTitle(snapshot.processTitle) setCustomTitle(snapshot.customTitle) + setCustomColor(snapshot.customColor) isPinned = snapshot.isPinned statusEntries = Dictionary( @@ -1323,7 +1325,11 @@ final class Workspace: Identifiable, ObservableObject { } func setCustomColor(_ hex: String?) { - customColor = hex + if let hex { + customColor = WorkspaceTabColorSettings.normalizedHex(hex) + } else { + customColor = nil + } } func setCustomTitle(_ title: String?) { diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 1fb01fa0..638e8794 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -25,6 +25,38 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs) } + func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false) + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].customColor = "#C0392B" + + XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL)) + + let loaded = SessionPersistenceStore.load(fileURL: snapshotURL) + XCTAssertEqual( + loaded?.windows.first?.tabManager.workspaces.first?.customColor, + "#C0392B" + ) + } + + func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws { + var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + snapshot.windows[0].tabManager.workspaces[0].customColor = nil + + let encoder = JSONEncoder() + let data = try encoder.encode(snapshot) + let json = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertFalse(json.contains("\"customColor\"")) + + let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: data) + XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor) + } + func testLoadRejectsSchemaVersionMismatch() { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) @@ -442,6 +474,7 @@ final class SessionPersistenceTests: XCTestCase { let workspace = SessionWorkspaceSnapshot( processTitle: "Terminal", customTitle: "Restored", + customColor: nil, isPinned: true, currentDirectory: "/tmp", focusedPanelId: nil, From 89756bad658666794f6e0fe41d00e5a0aebfdfe6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:28:48 -0800 Subject: [PATCH 068/136] Fix command palette caret color in light mode --- Sources/ContentView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a57755cf..dc0adaaf 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2465,7 +2465,7 @@ struct ContentView: View { TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) - .tint(.white) + .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused($isCommandPaletteSearchFocused) .onSubmit { runSelectedCommandPaletteResult(visibleResults: visibleResults) @@ -2618,7 +2618,7 @@ struct ContentView: View { TextField(target.placeholder, text: $commandPaletteRenameDraft) .textFieldStyle(.plain) .font(.system(size: 13, weight: .regular)) - .tint(.white) + .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) .focused($isCommandPaletteRenameFocused) .backport.onKeyPress(.delete) { modifiers in handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) From b57087f796653b966d8b6c59517920fdcd13c336 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:05:34 -0800 Subject: [PATCH 069/136] Implement cross-window tab and workspace move UI --- Sources/AppDelegate.swift | 301 ++++++++++++++++++++++++++++++++++++++ Sources/ContentView.swift | 148 +++++++++++++++++++ Sources/Workspace.swift | 125 ++++++++++++++++ vendor/bonsplit | 2 +- 4 files changed, 575 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 28c72ae1..2a7a3d86 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -959,6 +959,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selectedWorkspaceId: UUID? } + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: UUID { windowId } + } + + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" + } + } + func listMainWindowSummaries() -> [MainWindowSummary] { let contexts = Array(mainWindowContexts.values) return contexts.map { ctx in @@ -973,6 +996,235 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + return orderedSummaries.compactMap { summary in + guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } + let label = labels[summary.windowId] ?? "Window" + return WindowMoveTarget( + windowId: summary.windowId, + label: label, + tabManager: manager, + isCurrentWindow: summary.windowId == referenceWindowId + ) + } + } + + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) + + for summary in orderedSummaries { + guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } + let windowLabel = labels[summary.windowId] ?? "Window" + let isCurrentWindow = summary.windowId == referenceWindowId + for workspace in manager.tabs { + if workspace.id == excludingWorkspaceId { + continue + } + targets.append( + WorkspaceMoveTarget( + windowId: summary.windowId, + workspaceId: workspace.id, + windowLabel: windowLabel, + workspaceTitle: workspaceDisplayName(workspace), + tabManager: manager, + isCurrentWindow: isCurrentWindow + ) + ) + } + } + + return targets + } + + @discardableResult + func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { + guard let sourceManager = tabManagerFor(tabId: workspaceId), + let destinationManager = tabManagerFor(windowId: windowId) else { + return false + } + + if sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true + } + + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) + + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true + } + + @discardableResult + func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { + let windowId = createMainWindow() + guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + let bootstrapWorkspaceId = destinationManager.tabs.first?.id + + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil + } + + // Remove the bootstrap workspace from the new window once the moved workspace arrives. + if let bootstrapWorkspaceId, + bootstrapWorkspaceId != workspaceId, + let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + destinationManager.tabs.count > 1 { + destinationManager.closeWorkspace(bootstrapWorkspace) + } + return windowId + } + + func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let bonsplitTabId = TabID(uuid: tabId) + for context in mainWindowContexts.values { + for workspace in context.tabManager.tabs { + if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + return (context.windowId, workspace.id, panelId, context.tabManager) + } + } + } + return nil + } + + @discardableResult + func moveSurface( + panelId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { + guard let source = locateSurface(surfaceId: panelId), + let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), + let destinationManager = tabManagerFor(tabId: targetWorkspaceId), + let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { + return false + } + + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first + + guard let resolvedTargetPane else { return false } + + if destinationWorkspace.id == sourceWorkspace.id { + if let splitTarget { + guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), + sourceWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: sourceTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { + return false + } + if focus { + source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) + } + return true + } + + return sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) + } + + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) + + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { return false } + guard destinationWorkspace.attachDetachedSurface( + detached, + inPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) != nil else { + rollbackDetachedSurface( + detached, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + return false + } + + if let splitTarget { + guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), + destinationWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: movedTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { + if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { + rollbackDetachedSurface( + detachedFromDestination, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + } + return false + } + } + + if focus { + if focusWindow, let destinationWindowId = windowId(for: destinationManager) { + _ = focusMainWindow(windowId: destinationWindowId) + } + destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + } + + return true + } + + @discardableResult + func moveBonsplitTab( + tabId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true + ) -> Bool { + guard let located = locateBonsplitSurface(tabId: tabId) else { return false } + return moveSurface( + panelId: located.panelId, + toWorkspace: targetWorkspaceId, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: focus, + focusWindow: focusWindow + ) + } + func tabManagerFor(windowId: UUID) -> TabManager? { mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } @@ -1134,6 +1386,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { + let summaries = listMainWindowSummaries() + return summaries.sorted { lhs, rhs in + let lhsIsReference = lhs.windowId == referenceWindowId + let rhsIsReference = rhs.windowId == referenceWindowId + if lhsIsReference != rhsIsReference { return lhsIsReference } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + } + + private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { + var labels: [UUID: String] = [:] + for (index, summary) in orderedSummaries.enumerated() { + if summary.windowId == referenceWindowId { + labels[summary.windowId] = "Current Window" + } else { + labels[summary.windowId] = "Window \(index + 1)" + } + } + return labels + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Workspace" : trimmed + } + + private func rollbackDetachedSurface( + _ detached: Workspace.DetachedSurfaceTransfer, + to workspace: Workspace, + sourcePane: PaneID?, + sourceIndex: Int?, + focus: Bool + ) { + let rollbackPane = sourcePane.flatMap { pane in + workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let rollbackPane else { return } + _ = workspace.attachDetachedSurface( + detached, + inPane: rollbackPane, + atIndex: sourceIndex, + focus: focus + ) + } + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), let window = ctx.window { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index dc0adaaf..9c73af0e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -6239,6 +6239,12 @@ private struct TabItemView: View { dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) + .onDrop(of: [BonsplitTabDragPayload.typeIdentifier], delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) .onTapGesture { updateSelection() } @@ -6342,6 +6348,28 @@ private struct TabItemView: View { } .disabled(targetIds.isEmpty) + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 ? "Move Workspaces to Window" : "Move Workspace to Window" + Menu(moveMenuTitle) { + Button("New Window") { + moveWorkspacesToNewWindow(targetIds) + } + .disabled(targetIds.isEmpty) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveWorkspaces(targetIds, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || targetIds.isEmpty) + } + } + .disabled(targetIds.isEmpty) + Divider() if let key = closeWorkspaceShortcut.keyEquivalent { @@ -6570,6 +6598,43 @@ private struct TabItemView: View { } } + private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedWorkspaceIds.isEmpty else { return } + + for (index, workspaceId) in orderedWorkspaceIds.enumerated() { + let shouldFocus = index == orderedWorkspaceIds.count - 1 + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } + + let shouldFocusImmediately = orderedWorkspaceIds.count == 1 + guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { + return + } + + if orderedWorkspaceIds.count > 1 { + for workspaceId in orderedWorkspaceIds.dropFirst() { + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) + } + if let finalWorkspaceId = orderedWorkspaceIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) + } + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + private var latestNotificationText: String? { guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil } let text = notification.body.isEmpty ? notification.title : notification.body @@ -7127,6 +7192,89 @@ private enum SidebarTabDragPayload { } } +private enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + + struct Transfer: Decodable { + struct TabInfo: Decodable { + let id: UUID + } + + let tab: TabInfo + let sourcePaneId: UUID + } + + static func currentTransfer() -> Transfer? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(typeIdentifier) + + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data) { + return transfer + } + + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data) { + return transfer + } + + return nil + } +} + +private struct SidebarBonsplitTabDropDelegate: DropDelegate { + let targetWorkspaceId: UUID + let tabManager: TabManager + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + + func validateDrop(info: DropInfo) -> Bool { + guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } + return BonsplitTabDragPayload.currentTransfer() != nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared else { + return false + } + + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == targetWorkspaceId { + syncSidebarSelection() + return true + } + + guard app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: targetWorkspaceId, + focus: true, + focusWindow: true + ) else { + return false + } + + selectedTabIds = [targetWorkspaceId] + syncSidebarSelection() + return true + } + + private func syncSidebarSelection() { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + private struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? let tabManager: TabManager diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9697b271..fe19f64c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -506,6 +506,10 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.closeTab(welcomeTabId) } + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false + } + // Set ourselves as delegate bonsplitController.delegate = self @@ -2224,6 +2228,122 @@ final class Workspace: Identifiable, ObservableObject { setPanelCustomTitle(panelId: panelId, title: input.stringValue) } + private enum PanelMoveDestination { + case newWorkspaceInCurrentWindow + case selectedWorkspaceInNewWindow + case existingWorkspace(UUID) + } + + private func promptMovePanel(tabId: TabID) { + guard let panelId = panelIdFromSurfaceId(tabId), + let app = AppDelegate.shared else { return } + + let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) } + let workspaceTargets = app.workspaceMoveTargets( + excludingWorkspaceId: id, + referenceWindowId: currentWindowId + ) + + var options: [(title: String, destination: PanelMoveDestination)] = [ + ("New Workspace in Current Window", .newWorkspaceInCurrentWindow), + ("Selected Workspace in New Window", .selectedWorkspaceInNewWindow), + ] + options.append(contentsOf: workspaceTargets.map { target in + (target.label, .existingWorkspace(target.workspaceId)) + }) + + let alert = NSAlert() + alert.messageText = "Move Tab" + alert.informativeText = "Choose a destination for this tab." + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) + for option in options { + popup.addItem(withTitle: option.title) + } + popup.selectItem(at: 0) + alert.accessoryView = popup + alert.addButton(withTitle: "Move") + alert.addButton(withTitle: "Cancel") + + guard alert.runModal() == .alertFirstButtonReturn else { return } + let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) + let destination = options[selectedIndex].destination + + let moved: Bool + switch destination { + case .newWorkspaceInCurrentWindow: + guard let manager = app.tabManagerFor(tabId: id) else { return } + let workspace = manager.addWorkspace(select: true) + moved = app.moveSurface( + panelId: panelId, + toWorkspace: workspace.id, + focus: true, + focusWindow: false + ) + + case .selectedWorkspaceInNewWindow: + let newWindowId = app.createMainWindow() + guard let destinationManager = app.tabManagerFor(windowId: newWindowId), + let destinationWorkspaceId = destinationManager.selectedTabId else { + return + } + moved = app.moveSurface( + panelId: panelId, + toWorkspace: destinationWorkspaceId, + focus: true, + focusWindow: true + ) + if !moved { + _ = app.closeMainWindow(windowId: newWindowId) + } + + case .existingWorkspace(let workspaceId): + moved = app.moveSurface( + panelId: panelId, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) + } + + if !moved { + let failure = NSAlert() + failure.alertStyle = .warning + failure.messageText = "Move Failed" + failure.informativeText = "cmux could not move this tab to the selected destination." + failure.addButton(withTitle: "OK") + _ = failure.runModal() + } + } + + private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + guard let app = AppDelegate.shared else { return false } + + let targetPane: PaneID + let targetIndex: Int? + let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? + + switch request.destination { + case .insert(let paneId, let index): + targetPane = paneId + targetIndex = index + splitTarget = nil + case .split(let paneId, let orientation, let insertFirst): + targetPane = paneId + targetIndex = nil + splitTarget = (orientation, insertFirst) + } + + return app.moveBonsplitTab( + tabId: request.tabId.uuid, + toWorkspace: id, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: true, + focusWindow: true + ) + } + } // MARK: - BonsplitDelegate @@ -2878,6 +2998,8 @@ extension Workspace: BonsplitDelegate { closeTabs(tabIdsToRight(of: tab.id, inPane: pane)) case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) + case .move: + promptMovePanel(tabId: tab.id) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -2892,6 +3014,9 @@ extension Workspace: BonsplitDelegate { guard let panelId = panelIdFromSurfaceId(tab.id) else { return } let shouldPin = !pinnedPanelIds.contains(panelId) setPanelPinned(panelId: panelId, pinned: shouldPin) + case .markAsRead: + guard let panelId = panelIdFromSurfaceId(tab.id) else { return } + clearManualUnread(panelId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) diff --git a/vendor/bonsplit b/vendor/bonsplit index 2d0d05aa..1ec5120d 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849 +Subproject commit 1ec5120d94126f5c78e20618d426ee4ef5593c70 From d8022db404a74f081d167e39b6affab6a97ebe15 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:57:10 -0800 Subject: [PATCH 070/136] Handle moving the last surface out of a window --- Sources/AppDelegate.swift | 21 ++++++++++++++ Sources/Workspace.swift | 11 ++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 28 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2a7a3d86..a9735bd2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1193,6 +1193,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId + ) + if focus { if focusWindow, let destinationWindowId = windowId(for: destinationManager) { _ = focusMainWindow(windowId: destinationWindowId) @@ -1435,6 +1441,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: Workspace, + sourceManager: TabManager, + sourceWindowId: UUID + ) { + guard sourceWorkspace.panels.isEmpty else { return } + guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + + if sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) + } + } + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), let window = ctx.window { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index fe19f64c..79f5a14d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2671,9 +2671,16 @@ extension Workspace: BonsplitDelegate { lastTerminalConfigInheritancePanelId = nil } - // Keep the workspace invariant: always retain at least one real panel. - // This prevents runtime close callbacks from ever collapsing into a tabless workspace. + // Keep the workspace invariant for normal close paths. + // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can + // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { + if isDetaching { + scheduleTerminalGeometryReconcile() + scheduleFocusReconcile() + return + } + let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8484c957..7e75378d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2898,6 +2898,34 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testDetachLastSurfaceLeavesWorkspaceTemporarilyEmptyForMoveFlow() { + let workspace = Workspace() + guard let panelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: panelId) else { + XCTFail("Expected initial panel and pane") + return + } + + XCTAssertEqual(workspace.panels.count, 1) + + guard let detached = workspace.detachSurface(panelId: panelId) else { + XCTFail("Expected detach of last surface to succeed") + return + } + + XCTAssertEqual(detached.panelId, panelId) + XCTAssertTrue( + workspace.panels.isEmpty, + "Detaching the last surface should not auto-create a replacement panel" + ) + XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + + let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) + XCTAssertEqual(restoredPanelId, panelId) + XCTAssertEqual(workspace.panels.count, 1) + } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { From 2c2190b231458c04e2a95e18bdce4e42b5cf0d6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:33:19 -0800 Subject: [PATCH 071/136] Block tab drags across cmux app instances --- Sources/ContentView.swift | 26 ++++++++++++++++++++++++-- vendor/bonsplit | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9c73af0e..50ebfd3c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7194,6 +7194,7 @@ private enum SidebarTabDragPayload { private enum BonsplitTabDragPayload { static let typeIdentifier = "com.splittabbar.tabtransfer" + private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) struct Transfer: Decodable { struct TabInfo: Decodable { @@ -7202,6 +7203,25 @@ private enum BonsplitTabDragPayload { let tab: TabInfo let sourcePaneId: UUID + let sourceProcessId: Int32 + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabInfo.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + } + + private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { + transfer.sourceProcessId == currentProcessId } static func currentTransfer() -> Transfer? { @@ -7209,13 +7229,15 @@ private enum BonsplitTabDragPayload { let type = NSPasteboard.PasteboardType(typeIdentifier) if let data = pasteboard.data(forType: type), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data) { + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { return transfer } if let raw = pasteboard.string(forType: type), let data = raw.data(using: .utf8), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data) { + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { return transfer } diff --git a/vendor/bonsplit b/vendor/bonsplit index 1ec5120d..f24ba922 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 1ec5120d94126f5c78e20618d426ee4ef5593c70 +Subproject commit f24ba9222651ecc170869662eec9a5880404a82c From 946b0f28e65b5a6a4d87d28856d62d49bbec19d1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:48:55 -0800 Subject: [PATCH 072/136] Fix theme bg sync on appearance changes --- Sources/GhosttyTerminalView.swift | 47 ++++++++++++++++++++++++++++++ cmuxTests/GhosttyConfigTests.swift | 30 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 27541a3a..24002650 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -307,6 +307,7 @@ class GhosttyApp { private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" + private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference? private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = // Theme chrome should track terminal theme changes in the same frame. // Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame. @@ -565,6 +566,7 @@ class GhosttyApp { } // Notify observers that a usable config is available (initial load). + lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) #if os(macOS) @@ -615,6 +617,13 @@ class GhosttyApp { incomingScope.rawValue >= currentScope.rawValue } + static func shouldReloadConfigurationForAppearanceChange( + previousColorScheme: GhosttyConfig.ColorSchemePreference?, + currentColorScheme: GhosttyConfig.ColorSchemePreference + ) -> Bool { + previousColorScheme != currentColorScheme + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -671,6 +680,7 @@ class GhosttyApp { resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { ghostty_app_update_config(app, config) + lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return @@ -694,10 +704,39 @@ class GhosttyApp { ghostty_config_free(oldConfig) } config = newConfig + lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } + func synchronizeThemeWithAppearance(_ appearance: NSAppearance?, source: String) { + let currentColorScheme = GhosttyConfig.currentColorSchemePreference( + appAppearance: appearance ?? NSApp?.effectiveAppearance + ) + let shouldReload = Self.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: lastAppearanceColorScheme, + currentColorScheme: currentColorScheme + ) + if backgroundLogEnabled { + let previousLabel: String + switch lastAppearanceColorScheme { + case .light: + previousLabel = "light" + case .dark: + previousLabel = "dark" + case nil: + previousLabel = "nil" + } + let currentLabel: String = currentColorScheme == .dark ? "dark" : "light" + logBackground( + "appearance sync source=\(source) previous=\(previousLabel) current=\(currentLabel) reload=\(shouldReload)" + ) + } + guard shouldReload else { return } + lastAppearanceColorScheme = currentColorScheme + reloadConfiguration(source: "appearanceSync:\(source)") + } + func openConfigurationInTextEdit() { #if os(macOS) let path = ghosttyStringValue(ghostty_config_open_path()) @@ -2280,6 +2319,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) + GhosttyApp.shared.synchronizeThemeWithAppearance( + effectiveAppearance, + source: "surface.viewDidMoveToWindow" + ) applyWindowBackgroundIfActive() } @@ -2292,6 +2335,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ) } applySurfaceColorScheme() + GhosttyApp.shared.synchronizeThemeWithAppearance( + effectiveAppearance, + source: "surface.viewDidChangeEffectiveAppearance" + ) } fileprivate func updateOcclusionState() { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 1971d481..220767ba 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -195,6 +195,36 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testAppearanceChangeReloadsWhenColorSchemeChanges() { + XCTAssertTrue( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .dark, + currentColorScheme: .light + ) + ) + XCTAssertTrue( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: nil, + currentColorScheme: .dark + ) + ) + } + + func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() { + XCTAssertFalse( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .light, + currentColorScheme: .light + ) + ) + XCTAssertFalse( + GhosttyApp.shouldReloadConfigurationForAppearanceChange( + previousColorScheme: .dark, + currentColorScheme: .dark + ) + ) + } + func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { From 8736421e8c64ce2af2dfa51d2fb9415c79db8633 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:22:11 -0800 Subject: [PATCH 073/136] Prevent stale host visibility thrash after tab move --- Sources/GhosttyTerminalView.swift | 37 ++++++++++++++-- Sources/TerminalWindowPortal.swift | 15 +++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 42 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f42d79ae..d5d0d719 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4980,6 +4980,16 @@ struct GhosttyTerminalView: NSViewRepresentable { Coordinator() } + static func shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: Bool, + hostedViewHasSuperview: Bool, + isBoundToCurrentHost: Bool + ) -> Bool { + if !hostWindowAttached { return true } + if isBoundToCurrentHost { return true } + return !hostedViewHasSuperview + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -5022,8 +5032,6 @@ struct GhosttyTerminalView: NSViewRepresentable { // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) - hostedView.setVisibleInUI(isVisibleInUI) - hostedView.setActive(isActive) hostedView.setInactiveOverlay( color: inactiveOverlayColor, opacity: CGFloat(inactiveOverlayOpacity), @@ -5058,7 +5066,8 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration - if let host = nsView as? HostContainerView { + let hostContainer = nsView as? HostContainerView + if let host = hostContainer { host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } @@ -5109,6 +5118,28 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } } + + let hostWindowAttached = hostContainer?.window != nil + let isBoundToCurrentHost = hostContainer.map { host in + TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) + } ?? true + let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: hostWindowAttached, + hostedViewHasSuperview: hostedView.superview != nil, + isBoundToCurrentHost: isBoundToCurrentHost + ) + + if shouldApplyImmediateHostedState { + hostedView.setVisibleInUI(isVisibleInUI) + hostedView.setActive(isActive) + } else { + // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. + // The currently bound host remains authoritative for immediate visible/active state. + TerminalWindowPortalRegistry.updateEntryVisibility( + for: hostedView, + visibleInUI: isVisibleInUI + ) + } } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index e3f19c3d..2daddf4b 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -934,6 +934,12 @@ final class WindowTerminalPortal: NSObject { entriesByHostedId[hostedId] = entry } + func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool { + guard let entry = entriesByHostedId[hostedId], + let boundAnchor = entry.anchorView else { return false } + return boundAnchor === anchorView + } + func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -1462,6 +1468,15 @@ enum TerminalWindowPortalRegistry { portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) } + static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool { + let hostedId = ObjectIdentifier(hostedView) + guard let window = anchorView.window else { return false } + let windowId = ObjectIdentifier(window) + guard hostedToWindowId[hostedId] == windowId, + let portal = portalsByWindowId[windowId] else { return false } + return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView) + } + static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { let portal = portal(for: window) return portal.viewAtWindowPoint(windowPoint) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7e75378d..dcdc1eb2 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6223,3 +6223,45 @@ final class BrowserOmnibarFocusPolicyTests: XCTestCase { ) } } + +final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase { + func testImmediateStateUpdateAllowedWhenHostNotInWindow() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: false, + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenBoundToCurrentHost() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: true, + isBoundToCurrentHost: true + ) + ) + } + + func testImmediateStateUpdateSkippedForStaleHostBoundElsewhere() { + XCTAssertFalse( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: true, + isBoundToCurrentHost: false + ) + ) + } + + func testImmediateStateUpdateAllowedWhenUnboundAndNotAttachedAnywhere() { + XCTAssertTrue( + GhosttyTerminalView.shouldApplyImmediateHostedStateUpdate( + hostWindowAttached: true, + hostedViewHasSuperview: false, + isBoundToCurrentHost: false + ) + ) + } +} From a90e0a739ee01c9f2a0ff684f79367b5e3941fcf Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:36:17 -0800 Subject: [PATCH 074/136] Keep focus on destination after cross-window surface move --- Sources/Workspace.swift | 38 +++++++++++---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 47 +++++++++++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 79f5a14d..4aee7153 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -566,6 +566,9 @@ final class Workspace: Identifiable, ObservableObject { private var pendingTabSelection: (tabId: TabID, pane: PaneID)? private var isReconcilingFocusState = false private var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 +#endif private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? @@ -594,6 +597,8 @@ final class Workspace: Identifiable, ObservableObject { private var detachingTabIds: Set = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] + private var activeDetachCloseTransactions: Int = 0 + private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] @@ -1684,6 +1689,8 @@ final class Workspace: Identifiable, ObservableObject { detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) + activeDetachCloseTransactions += 1 + defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) @@ -2129,6 +2136,11 @@ final class Workspace: Identifiable, ObservableObject { /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { +#if DEBUG + if isDetachingCloseTransaction { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif guard !focusReconcileScheduled else { return } focusReconcileScheduled = true DispatchQueue.main.async { [weak self] in @@ -2613,6 +2625,7 @@ extension Workspace: BonsplitDelegate { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { @@ -2620,7 +2633,9 @@ extension Workspace: BonsplitDelegate { NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } return } @@ -2628,7 +2643,6 @@ extension Workspace: BonsplitDelegate { NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") #endif - let isDetaching = detachingTabIds.remove(tabId) != nil let panel = panels[panelId] if isDetaching, let panel { @@ -2677,7 +2691,6 @@ extension Workspace: BonsplitDelegate { if panels.isEmpty { if isDetaching { scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() return } @@ -2712,7 +2725,9 @@ extension Workspace: BonsplitDelegate { normalizePinnedTabs(in: pane) } scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { @@ -2732,7 +2747,9 @@ extension Workspace: BonsplitDelegate { normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { @@ -2754,6 +2771,7 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] + let shouldScheduleFocusReconcile = !isDetachingCloseTransaction if !closedPanelIds.isEmpty { for panelId in closedPanelIds { @@ -2778,13 +2796,15 @@ extension Workspace: BonsplitDelegate { if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } else { + } else if shouldScheduleFocusReconcile { scheduleFocusReconcile() } } scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { @@ -3035,7 +3055,9 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index dcdc1eb2..7f5dcb51 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2907,6 +2907,9 @@ final class WorkspacePanelGitBranchTests: XCTestCase { } XCTAssertEqual(workspace.panels.count, 1) +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif guard let detached = workspace.detachSurface(panelId: panelId) else { XCTFail("Expected detach of last surface to succeed") @@ -2921,11 +2924,55 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation" + ) +#endif + let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) XCTAssertEqual(restoredPanelId, panelId) XCTAssertEqual(workspace.panels.count, 1) } + func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { + let workspace = Workspace() + guard let originalPanelId = workspace.focusedPanelId, + let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { + XCTFail("Expected two panels before detach") + return + } + + drainMainQueue() + drainMainQueue() +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: movedPanel.id) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.panelId, movedPanel.id) + XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel") + XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach") + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching into another workspace should not enqueue delayed source focus reconciliation" + ) +#endif + } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { From 5c65e25b66c40b9679991912ba6c515801a2174b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:44:53 -0800 Subject: [PATCH 075/136] Reassert destination focus after cross-window tab moves --- Sources/AppDelegate.swift | 45 ++++++++++++++++++++++++++++++++++++++- Sources/TabManager.swift | 9 ++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a9735bd2..e27c6195 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1200,10 +1200,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) if focus { - if focusWindow, let destinationWindowId = windowId(for: destinationManager) { + let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil + if let destinationWindowId { _ = focusMainWindow(windowId: destinationWindowId) } destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + if let destinationWindowId { + reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: destinationWindowId, + sourceWindowId: source.windowId, + destinationWorkspaceId: targetWorkspaceId, + destinationPanelId: panelId, + destinationManager: destinationManager + ) + } } return true @@ -1456,6 +1466,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: UUID, + sourceWindowId: UUID, + destinationWorkspaceId: UUID, + destinationPanelId: UUID, + destinationManager: TabManager + ) { + let reassert: () -> Void = { [weak self, weak destinationManager] in + guard let self, let destinationManager else { return } + guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), + workspace.panels[destinationPanelId] != nil else { + return + } + guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } + guard let keyWindow = NSApp.keyWindow, + let keyWindowId = self.mainWindowId(for: keyWindow), + keyWindowId == sourceWindowId, + keyWindow !== destinationWindow else { + return + } + + self.bringToFront(destinationWindow) + destinationManager.focusTab( + destinationWorkspaceId, + surfaceId: destinationPanelId, + suppressFlash: true + ) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + } + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), let window = ctx.window { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5a59b82a..0f2242d6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1523,10 +1523,15 @@ class TabManager: ObservableObject { userInfo: [GhosttyNotificationKey.tabId: tabId] ) - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self else { return } NSApp.activate(ignoringOtherApps: true) NSApp.unhide(nil) - if let window = NSApp.keyWindow ?? NSApp.windows.first { + if let app = AppDelegate.shared, + let windowId = app.windowId(for: self), + let window = app.mainWindow(for: windowId) { + window.makeKeyAndOrderFront(nil) + } else if let window = NSApp.keyWindow ?? NSApp.windows.first { window.makeKeyAndOrderFront(nil) } } From 66a9435da6b55ae5b30dd42c342121023319868a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:52:01 -0800 Subject: [PATCH 076/136] Add background source diagnostics for theme sync --- Sources/GhosttyTerminalView.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 24002650..914fc57b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1230,6 +1230,11 @@ class GhosttyApp { blue: CGFloat(change.b) / 255, alpha: 1.0 ) + if backgroundLogEnabled { + logBackground( + "surface override set tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface" + ) + } surfaceView.applySurfaceBackground() if backgroundLogEnabled { logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")") @@ -1247,7 +1252,7 @@ class GhosttyApp { ) if backgroundLogEnabled { logBackground( - "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" + "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())" ) } return true @@ -2210,8 +2215,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedSurfaceBackgroundSignature { lastLoggedSurfaceBackgroundSignature = signature + let hasOverride = backgroundColor != nil + let overrideHex = backgroundColor?.hexString() ?? "nil" + let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() + let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( - "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } @@ -2235,8 +2244,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let signature = "\(cmuxShouldUseTransparentBackgroundWindow() ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedWindowBackgroundSignature { lastLoggedWindowBackgroundSignature = signature + let hasOverride = backgroundColor != nil + let overrideHex = backgroundColor?.hexString() ?? "nil" + let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() + let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( - "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") transparent=\(cmuxShouldUseTransparentBackgroundWindow()) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseTransparentBackgroundWindow()) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } From 1c3f8458ee282c533a2cd5ce00d3904a74ec8f48 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:54:54 -0800 Subject: [PATCH 077/136] Clear stale surface bg override on config changes --- Sources/GhosttyTerminalView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 914fc57b..aaea7a19 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1245,6 +1245,18 @@ class GhosttyApp { } return true case GHOSTTY_ACTION_CONFIG_CHANGE: + if let staleOverride = surfaceView.backgroundColor { + surfaceView.backgroundColor = nil + if backgroundLogEnabled { + logBackground( + "surface override cleared tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") cleared=\(staleOverride.hexString()) source=action.config_change.surface" + ) + } + surfaceView.applySurfaceBackground() + DispatchQueue.main.async { + surfaceView.applyWindowBackgroundIfActive() + } + } updateDefaultBackground( from: action.action.config_change.config, source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", From 8f94fd0f503d353a219d464792fa562da26aadf3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Feb 2026 19:56:38 -0800 Subject: [PATCH 078/136] Fix stale browser favicon after navigation --- Sources/Panels/BrowserPanel.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 420ba6d1..39805285 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1647,6 +1647,9 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask?.cancel() faviconTask = nil lastFaviconURLString = nil + // Clear the previous page's favicon so it never persists across navigations. + // The loading spinner covers this gap; didFinish will fetch the new favicon. + faviconPNGData = nil loadingGeneration &+= 1 loadingEndWorkItem?.cancel() loadingEndWorkItem = nil @@ -2526,6 +2529,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) + // Treat committed-navigation failures the same as provisional ones so + // stale favicon/title state from the prior page gets cleared. + let failedURL = webView.url?.absoluteString ?? "" + didFailNavigation?(webView, failedURL) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { From e5448ac48a34fb7f5af6fb3a7ebc06b2f133a862 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Feb 2026 19:56:41 -0800 Subject: [PATCH 079/136] Fix stuck titlebar drag suppression --- Sources/ContentView.swift | 7 +++---- Sources/WindowDragHandleView.swift | 31 +++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 50ebfd3c..67d8987d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7645,7 +7645,6 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { override func hitTest(_ point: NSPoint) -> NSView? { guard bounds.contains(point) else { return nil } - maybeDisableWindowDraggingEarly(trigger: "hitTest") let hit = super.hitTest(point) #if DEBUG let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" @@ -7685,9 +7684,9 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { override func mouseUp(with event: NSEvent) { super.mouseUp(with: event) - if !hasActiveDragSession { - restoreWindowMovableStateIfNeeded() - } + // Always restore suppression on mouse-up; drag-session callbacks can be + // skipped for non-started drags, which would otherwise leave suppression stuck. + restoreWindowMovableStateIfNeeded() } override func rightMouseDown(with event: NSEvent) { diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index ebc62a05..e9ce93f4 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -51,6 +51,16 @@ func isWindowDragSuppressed(window: NSWindow?) -> Bool { windowDragSuppressionDepth(window: window) > 0 } +@discardableResult +func clearWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + var depth = windowDragSuppressionDepth(window: window) + while depth > 0 { + depth = endWindowDragSuppression(window: window) + } + return depth +} + /// Temporarily enables window movability for explicit drag-handle drags, then /// restores the previous movability state after `body` finishes. @discardableResult @@ -98,13 +108,24 @@ func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { /// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool { if isWindowDragSuppressed(window: dragHandleView.window) { + // Recover from stale suppression if a prior interaction missed cleanup. + // We only keep suppression active while the left mouse button is down. + if (NSEvent.pressedMouseButtons & 0x1) == 0 { + let clearedDepth = clearWindowDragSuppression(window: dragHandleView.window) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + } else { #if DEBUG - let depth = windowDragSuppressionDepth(window: dragHandleView.window) - dlog( - "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" - ) + let depth = windowDragSuppressionDepth(window: dragHandleView.window) + dlog( + "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" + ) #endif - return false + return false + } } guard dragHandleView.bounds.contains(point) else { From a97e0edea8eadae895029e1ca66a06cc01d10e00 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:05:12 -0800 Subject: [PATCH 080/136] Add drag transfer timing logs for bonsplit tabs --- Sources/AppDelegate.swift | 173 ++++++++++++++++++++++++++++++++++++-- Sources/Workspace.swift | 112 ++++++++++++++++++++++-- 2 files changed, 271 insertions(+), 14 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e27c6195..34497d6c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1113,19 +1113,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: Bool = true, focusWindow: Bool = true ) -> Bool { - guard let source = locateSurface(surfaceId: panelId), - let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }), - let destinationManager = tabManagerFor(tabId: targetWorkspaceId), - let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" + ) +#endif + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif return false } + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } +#if DEBUG + dlog( + "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" + ) +#endif let resolvedTargetPane = targetPane.flatMap { pane in destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) } ?? destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first - guard let resolvedTargetPane else { return false } + guard let resolvedTargetPane else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } if destinationWorkspace.id == sourceWorkspace.id { if let splitTarget { @@ -1136,26 +1184,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent movingTab: sourceTabId, insertFirst: splitTarget.insertFirst ) != nil else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } if focus { source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return true } - return sourceWorkspace.moveSurface( + let moved = sourceWorkspace.moveSurface( panelId: panelId, toPane: resolvedTargetPane, atIndex: targetIndex, focus: focus ) +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved } let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime +#endif - guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { return false } + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime +#endif guard destinationWorkspace.attachDetachedSurface( detached, inPane: resolvedTargetPane, @@ -1169,10 +1253,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sourceIndex: sourceIndex, focus: focus ) +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } +#if DEBUG + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" +#endif if let splitTarget { +#if DEBUG + let splitStart = ProcessInfo.processInfo.systemUptime +#endif guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), destinationWorkspace.bonsplitController.splitPane( resolvedTargetPane, @@ -1189,15 +1286,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: focus ) } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif } +#if DEBUG + let cleanupStart = ProcessInfo.processInfo.systemUptime +#endif cleanupEmptySourceWorkspaceAfterSurfaceMove( sourceWorkspace: sourceWorkspace, sourceManager: source.tabManager, sourceWindowId: source.windowId ) +#if DEBUG + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime +#endif if focus { let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil @@ -1215,6 +1328,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } } +#if DEBUG + let focusMs = elapsedMs(since: focusStart) + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return true } @@ -1229,8 +1352,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: Bool = true, focusWindow: Bool = true ) -> Bool { - guard let located = locateBonsplitSurface(tabId: tabId) else { return false } - return moveSurface( +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" + ) +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + dlog( + "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" + ) +#endif + let moved = moveSurface( panelId: located.panelId, toWorkspace: targetWorkspaceId, targetPane: targetPane, @@ -1239,6 +1387,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focus: focus, focusWindow: focusWindow ) +#if DEBUG + dlog( + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved } func tabManagerFor(windowId: UUID) -> TabManager? { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4aee7153..8877961d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -568,6 +568,8 @@ final class Workspace: Identifiable, ObservableObject { private var focusReconcileScheduled = false #if DEBUG private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 + private var debugLastDidMoveTabTimestamp: TimeInterval = 0 + private var debugDidMoveTabEventCount: UInt64 = 0 #endif private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false @@ -600,6 +602,13 @@ final class Workspace: Identifiable, ObservableObject { private var activeDetachCloseTransactions: Int = 0 private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } +#if DEBUG + private func debugElapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } +#endif + func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] } @@ -1686,6 +1695,14 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + + "pendingDetached=\(pendingDetachedSurfaces.count)" + ) +#endif detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) @@ -1695,10 +1712,24 @@ final class Workspace: Identifiable, ObservableObject { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) +#if DEBUG + dlog( + "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif return nil } - return pendingDetachedSurfaces.removeValue(forKey: tabId) + let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) +#if DEBUG + dlog( + "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif + return detached } @discardableResult @@ -1708,8 +1739,31 @@ final class Workspace: Identifiable, ObservableObject { atIndex index: Int? = nil, focus: Bool = true ) -> UUID? { - guard bonsplitController.allPaneIds.contains(paneId) else { return nil } - guard panels[detached.panelId] == nil else { return nil } +#if DEBUG + let attachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" + ) +#endif + guard bonsplitController.allPaneIds.contains(paneId) else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } + guard panels[detached.panelId] == nil else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } panels[detached.panelId] = detached.panel if let terminalPanel = detached.panel as? TerminalPanel { @@ -1760,6 +1814,12 @@ final class Workspace: Identifiable, ObservableObject { manualUnreadPanelIds.remove(detached.panelId) manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return nil } @@ -1781,6 +1841,14 @@ final class Workspace: Identifiable, ObservableObject { } scheduleTerminalGeometryReconcile() +#if DEBUG + dlog( + "split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " + + "index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return detached.panelId } // MARK: - Focus Management @@ -2329,23 +2397,41 @@ final class Workspace: Identifiable, ObservableObject { private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { guard let app = AppDelegate.shared else { return false } +#if DEBUG + let dropStart = ProcessInfo.processInfo.systemUptime +#endif let targetPane: PaneID let targetIndex: Int? let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? +#if DEBUG + let destinationLabel: String +#endif switch request.destination { case .insert(let paneId, let index): targetPane = paneId targetIndex = index splitTarget = nil +#if DEBUG + destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")" +#endif case .split(let paneId, let orientation, let insertFirst): targetPane = paneId targetIndex = nil splitTarget = (orientation, insertFirst) +#if DEBUG + destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)" +#endif } - return app.moveBonsplitTab( + #if DEBUG + dlog( + "split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)" + ) + #endif + let moved = app.moveBonsplitTab( tabId: request.tabId.uuid, toWorkspace: id, targetPane: targetPane, @@ -2354,6 +2440,13 @@ final class Workspace: Identifiable, ObservableObject { focus: true, focusWindow: true ) +#if DEBUG + dlog( + "split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))" + ) +#endif + return moved } } @@ -2736,9 +2829,18 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG + let now = ProcessInfo.processInfo.systemUptime + let sincePrev: String + if debugLastDidMoveTabTimestamp > 0 { + sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) + } else { + sincePrev = "first" + } + debugLastDidMoveTabTimestamp = now + debugDidMoveTabEventCount += 1 let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" dlog( - "split.moveTab panel=\(movedPanel) " + + "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) From 3c1650d3e099159e70d473ac06f3dffb808dddf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:08:21 -0800 Subject: [PATCH 081/136] Fix Cmd+P/Cmd+Shift+P window routing (#413) * Fix command palette shortcuts to stay window-scoped * Fix cross-window command palette typing focus lock --- Sources/AppDelegate.swift | 22 +++- Sources/ContentView.swift | 56 +++++++- tests_v2/test_command_palette_window_scope.py | 124 +++++++++++++++++- 3 files changed, 191 insertions(+), 11 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e27c6195..befa7e43 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1535,6 +1535,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow + } + return activeCommandPaletteWindow() + } + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { guard let window, isMainTerminalWindow(window) else { return nil } return mainWindowContexts[ObjectIdentifier(window)] @@ -2950,13 +2957,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) + let commandPaletteVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteVisible(for: $0) + } ?? false if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( flags: event.modifierFlags, chars: chars, keyCode: event.keyCode ), - let paletteWindow = activeCommandPaletteWindow() { + commandPaletteVisibleInTargetWindow, + let paletteWindow = commandPaletteTargetWindow { NotificationCenter.default.post( name: .commandPaletteMoveSelection, object: paletteWindow, @@ -2967,20 +2979,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35) if isCommandP { - let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) return true } let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) if isCommandShiftP { - let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) return true } if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: activeCommandPaletteWindow() != nil, + isCommandPaletteVisible: commandPaletteVisibleInTargetWindow, normalizedFlags: normalizedFlags, chars: chars, keyCode: event.keyCode @@ -3184,7 +3196,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if tabManager?.focusedBrowserPanel != nil { return false } - let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) return true } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 67d8987d..bbb4cbe5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -753,6 +753,9 @@ private final class WindowCommandPaletteOverlayController: NSObject { private weak var installedThemeFrame: NSView? private var focusLockTimer: DispatchSourceTimer? private var scheduledFocusWorkItem: DispatchWorkItem? + private var isPaletteVisible = false + private var windowDidBecomeKeyObserver: NSObjectProtocol? + private var windowDidResignKeyObserver: NSObjectProtocol? init(window: NSWindow) { self.window = window @@ -775,6 +778,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), ]) _ = ensureInstalled() + installWindowKeyObservers() } @discardableResult @@ -906,6 +910,52 @@ private final class WindowCommandPaletteOverlayController: NSObject { } } + private func installWindowKeyObservers() { + guard let window else { return } + windowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFocusLockForWindowState() + } + } + windowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFocusLockForWindowState() + } + } + } + + private func updateFocusLockForWindowState() { + guard let window else { + stopFocusLockTimer() + return + } + guard isPaletteVisible else { + stopFocusLockTimer() + return + } + + guard window.isKeyWindow else { + stopFocusLockTimer() + if isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + return + } + + startFocusLockTimer() + if !isPaletteTextInputFirstResponder(window.firstResponder) { + scheduleFocusIntoPalette(retries: 8) + } + } + private func startFocusLockTimer() { guard focusLockTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .main) @@ -952,6 +1002,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { func update(rootView: AnyView, isVisible: Bool) { guard ensureInstalled() else { return } + isPaletteVisible = isVisible if isVisible { hostingView.rootView = rootView containerView.capturesMouseEvents = true @@ -960,10 +1011,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { if let themeFrame = installedThemeFrame, themeFrame.subviews.last !== containerView { themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) } - startFocusLockTimer() - if let window, !isPaletteTextInputFirstResponder(window.firstResponder) { - scheduleFocusIntoPalette(retries: 8) - } + updateFocusLockForWindowState() } else { stopFocusLockTimer() if let window, isPaletteResponder(window.firstResponder) { diff --git a/tests_v2/test_command_palette_window_scope.py b/tests_v2/test_command_palette_window_scope.py index 63236c34..e6cfeab7 100644 --- a/tests_v2/test_command_palette_window_scope.py +++ b/tests_v2/test_command_palette_window_scope.py @@ -32,6 +32,10 @@ def _palette_visible(client: cmux, window_id: str) -> bool: return bool(res.get("visible")) +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: if _palette_visible(client, window_id) == visible: return @@ -43,6 +47,116 @@ def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: ) +def _focus_window(client: cmux, window_id: str) -> None: + client.focus_window(window_id) + client.activate_app() + _wait_until( + lambda: client.current_window().lower() == window_id.lower(), + timeout_s=3.0, + message=f"failed to focus window {window_id}", + ) + time.sleep(0.15) + + +def _assert_shortcut_window_scoped(client: cmux, shortcut: str, w1: str, w2: str) -> None: + _set_palette_visible(client, w1, False) + _set_palette_visible(client, w2, False) + + _focus_window(client, w1) + client.simulate_shortcut(shortcut) + _wait_until( + lambda: _palette_visible(client, w1), + timeout_s=3.0, + message=f"{shortcut} did not open palette in window1", + ) + if _palette_visible(client, w2): + raise cmuxError(f"{shortcut} in window1 incorrectly opened palette in window2") + + _focus_window(client, w2) + client.simulate_shortcut(shortcut) + _wait_until( + lambda: _palette_visible(client, w2), + timeout_s=3.0, + message=f"{shortcut} did not open palette in window2", + ) + if not _palette_visible(client, w1): + raise cmuxError( + f"{shortcut} in window2 incorrectly toggled window1 palette off " + "(cross-window routing regression)" + ) + + client.simulate_shortcut(shortcut) + _wait_until( + lambda: not _palette_visible(client, w2), + timeout_s=3.0, + message=f"second {shortcut} did not close palette in window2", + ) + if not _palette_visible(client, w1): + raise cmuxError( + f"second {shortcut} in window2 incorrectly changed window1 palette visibility" + ) + + _focus_window(client, w1) + client.simulate_shortcut(shortcut) + _wait_until( + lambda: not _palette_visible(client, w1), + timeout_s=3.0, + message=f"second {shortcut} did not close palette in window1", + ) + + +def _assert_cross_window_typing_after_mixed_shortcuts(client: cmux, w1: str, w2: str) -> None: + _set_palette_visible(client, w1, False) + _set_palette_visible(client, w2, False) + + _focus_window(client, w1) + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: _palette_visible(client, w1), + timeout_s=3.0, + message="cmd+shift+p did not open palette in window1", + ) + _wait_until( + lambda: str(_palette_results(client, w1).get("mode") or "") == "commands", + timeout_s=3.0, + message="window1 palette did not enter commands mode", + ) + window1_query_before = str(_palette_results(client, w1).get("query") or "") + + _focus_window(client, w2) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, w2), + timeout_s=3.0, + message="cmd+p did not open palette in window2", + ) + _wait_until( + lambda: str(_palette_results(client, w2).get("mode") or "") == "switcher", + timeout_s=3.0, + message="window2 palette did not enter switcher mode", + ) + + typed = "" + for ch in "crosswindow": + typed += ch + client.simulate_type(ch) + _wait_until( + lambda expected=typed: str(_palette_results(client, w2).get("query") or "").lower() == expected, + timeout_s=1.8, + message=( + "typing into window2 palette did not accumulate query text " + f"(expected {typed!r})" + ), + ) + + window1_query_now = str(_palette_results(client, w1).get("query") or "") + if window1_query_now != window1_query_before: + raise cmuxError( + "typing in window2 changed window1 command-palette query " + f"(before={window1_query_before!r}, now={window1_query_now!r})" + ) + + def main() -> int: with cmux(SOCKET_PATH) as client: client.activate_app() @@ -51,8 +165,8 @@ def main() -> int: w2 = client.new_window() time.sleep(0.25) - ws1 = client.new_workspace(window_id=w1) - ws2 = client.new_workspace(window_id=w2) + _ = client.new_workspace(window_id=w1) + _ = client.new_workspace(window_id=w2) time.sleep(0.25) _set_palette_visible(client, w1, False) _set_palette_visible(client, w2, False) @@ -91,6 +205,12 @@ def main() -> int: message="window2 command palette did not close", ) + # Reproduce keyboard-shortcut window-scoping path: + # opening from window2 must not jump back and toggle window1. + _assert_shortcut_window_scoped(client, "cmd+shift+p", w1, w2) + _assert_shortcut_window_scoped(client, "cmd+p", w1, w2) + _assert_cross_window_typing_after_mixed_shortcuts(client, w1, w2) + print("PASS: command palette is scoped to active window") return 0 From f502f841442b9115b5a21390bbae403a5d5c9dc5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:09:44 -0800 Subject: [PATCH 082/136] Add Read the Docs link below bottom CTA on homepage (#411) * Add "Read the Docs" link below bottom CTA on homepage * Increase top padding on Read the Docs link * Reduce docs horizontal padding on mobile * Align docs content with header on mobile, increase top padding * Make sticky header fully opaque with subtle bottom border * Fix sticky header on mobile by containing horizontal overflow in content area Wide content (hierarchy diagrams, code blocks) was causing horizontal page scroll, which breaks position:sticky on mobile browsers. Added overflow-x:hidden to the main content area (below the header in DOM) and overflow-x:auto to docs pre blocks so they scroll internally. * Remove bottom border from sticky header --- web/app/components/site-header.tsx | 2 +- web/app/docs/docs-nav.tsx | 6 +++--- web/app/globals.css | 5 +++++ web/app/page.tsx | 8 ++++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/web/app/components/site-header.tsx b/web/app/components/site-header.tsx index ad7e6202..bc4da923 100644 --- a/web/app/components/site-header.tsx +++ b/web/app/components/site-header.tsx @@ -22,7 +22,7 @@ export function SiteHeader({ return ( <> -
+
{/* Left: logo + section */}
diff --git a/web/app/docs/docs-nav.tsx b/web/app/docs/docs-nav.tsx index 30827c7d..243fac73 100644 --- a/web/app/docs/docs-nav.tsx +++ b/web/app/docs/docs-nav.tsx @@ -11,7 +11,7 @@ export function DocsNav({ children }: { children: React.ReactNode }) { const { open, toggle, close, drawerRef, buttonRef } = useMobileDrawer(); return ( -
+
{/* Mobile menu button */}
)}
-        {children}
+        
+          {children}
+        
       
); diff --git a/web/app/globals.css b/web/app/globals.css index a10df622..0ecea6bb 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -168,6 +168,7 @@ body { background: none; padding: 0; font-size: 1em; + font-family: inherit; } /* Shiki dual theme */ From fb1802a54d64c0a228c5ea58398f845b62a51940 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:20:55 -0800 Subject: [PATCH 084/136] Guard terminal onFocus from re-entrant focus loops --- Sources/GhosttyTerminalView.swift | 4 +- Sources/Workspace.swift | 33 ++++++++-- Sources/WorkspaceContentView.swift | 2 +- ..._focus_panel_reentrant_guard_regression.py | 64 +++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/test_focus_panel_reentrant_guard_regression.py diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d5d0d719..15068928 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2517,6 +2517,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() + var shouldApplySurfaceFocus = false if result { // If we become first responder before the ghostty surface exists (e.g. during // split/tab creation while the surface is still being created), record the desired focus. @@ -2538,6 +2539,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // stayed behind. let hiddenInHierarchy = isHiddenOrHasHiddenAncestor if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { + shouldApplySurfaceFocus = true onFocus?() } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { #if DEBUG @@ -2548,7 +2550,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #endif } } - if result, let surface = ensureSurfaceReadyForInput() { + if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8877961d..9ea80bd3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -347,6 +347,11 @@ final class Workspace: Identifiable, ObservableObject { return panel } + enum FocusPanelTrigger { + case standard + case terminalFirstResponder + } + /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @@ -1940,12 +1945,19 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } - func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + func focusPanel( + _ panelId: UUID, + previousHostedView: GhosttySurfaceScrollView? = nil, + trigger: FocusPanelTrigger = .standard + ) { markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") - FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)") + let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") + FocusLogStore.shared.append( + "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" + ) #endif guard let tabId = surfaceIdFromPanelId(panelId) else { return } let currentlyFocusedPanelId = focusedPanelId @@ -1968,6 +1980,15 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() + let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged +#if DEBUG + if shouldSuppressReentrantRefocus { + dlog( + "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + + "reason=firstResponderAlreadyConverged" + ) + } +#endif if let targetPaneId, !selectionAlreadyConverged { bonsplitController.focusPane(targetPaneId) @@ -1979,11 +2000,11 @@ final class Workspace: Identifiable, ObservableObject { // Also focus the underlying panel if let panel = panels[panelId] { - if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged { + if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus { panel.focus() } - if let terminalPanel = panel as? TerminalPanel { + if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel { // Avoid re-entrant focus loops when focus was initiated by AppKit first-responder // (becomeFirstResponder -> onFocus -> focusPanel). if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { @@ -1991,7 +2012,7 @@ final class Workspace: Identifiable, ObservableObject { } } } - if let targetPaneId { + if let targetPaneId, !shouldSuppressReentrantRefocus { applyTabSelection(tabId: tabId, inPane: targetPaneId) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index d209b4d2..392f9986 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -67,7 +67,7 @@ struct WorkspaceContentView: View { // indicator and where keyboard input/flash-focus actually lands. guard isWorkspaceInputActive else { return } guard workspace.panels[panel.id] != nil else { return } - workspace.focusPanel(panel.id) + workspace.focusPanel(panel.id, trigger: .terminalFirstResponder) }, onRequestPanelFocus: { guard isWorkspaceInputActive else { return } diff --git a/tests/test_focus_panel_reentrant_guard_regression.py b/tests/test_focus_panel_reentrant_guard_regression.py new file mode 100644 index 00000000..fbe2a5c3 --- /dev/null +++ b/tests/test_focus_panel_reentrant_guard_regression.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Static regression checks for re-entrant terminal focus guard. + +Guards the fix for split-drag focus churn where: +becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects +could repeatedly re-enter and spike CPU. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + workspace_path = root / "Sources" / "Workspace.swift" + workspace_source = workspace_path.read_text(encoding="utf-8") + + required_workspace_snippets = [ + "enum FocusPanelTrigger {", + "case terminalFirstResponder", + "trigger: FocusPanelTrigger = .standard", + "let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged", + "if let targetPaneId, !shouldSuppressReentrantRefocus {", + "reason=firstResponderAlreadyConverged", + ] + for snippet in required_workspace_snippets: + if snippet not in workspace_source: + failures.append(f"Workspace focus guard missing snippet: {snippet}") + + workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift" + workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8") + focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)" + if focus_callback_snippet not in workspace_content_view_source: + failures.append( + "WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger" + ) + + if failures: + print("FAIL: focus-panel re-entrant guard regression checks failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: focus-panel re-entrant guard is in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b0d86a4f0ddf0b59289df0e83a5923fa13919993 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Feb 2026 20:45:25 -0800 Subject: [PATCH 085/136] Fix double-click titlebar zoom not working on browser panel side The browser portal's hit-test was intercepting clicks in the titlebar region, preventing double-click-to-zoom from reaching the window. Three fixes: - BrowserWindowPortal: pass through hits landing in the native titlebar - WindowDragHandleView: only let titlebar-overlay views block capture, not underlay browser content; respect AppleActionOnDoubleClick pref - ContentView: use shared performStandardTitlebarDoubleClick() helper Closes https://github.com/manaflow-ai/cmux/issues/422 Co-Authored-By: Claude Opus 4.6 --- Sources/BrowserWindowPortal.swift | 11 +++++++ Sources/ContentView.swift | 11 +++---- Sources/WindowDragHandleView.swift | 50 +++++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 2e82cb66..448f0e46 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -117,6 +117,9 @@ final class WindowBrowserHostView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { updateDividerCursor(at: point) + if shouldPassThroughToTitlebar(at: point) { + return nil + } if shouldPassThroughToSidebarResizer(at: point) { return nil } @@ -127,6 +130,14 @@ final class WindowBrowserHostView: NSView { return hitView === self ? nil : hitView } + private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool { + guard let window else { return false } + // Window-level portal hosts sit above SwiftUI content. Never intercept + // hits that land in the native titlebar region. + let windowPoint = convert(point, to: nil) + return windowPoint.y >= (window.contentLayoutRect.maxY - 0.5) + } + private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { // Browser portal host sits above SwiftUI content. Allow pointer/mouse events // to reach the SwiftUI sidebar divider resizer zone. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bbb4cbe5..3a21ed9d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5241,8 +5241,8 @@ struct VerticalTabsSidebar: View { .allowsHitTesting(false) } .overlay(alignment: .top) { - // Double-click the sidebar title-bar area to zoom the - // window, matching the panel top-bar behaviour. + // Double-click the sidebar title-bar area to trigger the + // standard macOS titlebar action (zoom/minimize). DoubleClickZoomView() .frame(height: trafficLightPadding) } @@ -7492,11 +7492,10 @@ private struct DoubleClickZoomView: NSViewRepresentable { override var mouseDownCanMoveWindow: Bool { true } override func hitTest(_ point: NSPoint) -> NSView? { self } override func mouseDown(with event: NSEvent) { - if event.clickCount == 2 { - window?.zoom(nil) - } else { - super.mouseDown(with: event) + if event.clickCount == 2, performStandardTitlebarDoubleClick(window: window) { + return } + super.mouseDown(with: event) } } } diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index e9ce93f4..a468c088 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -6,6 +6,40 @@ private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { String(format: "(%.1f,%.1f)", point.x, point.y) } +/// Runs the same action macOS titlebars use for double-click: +/// zoom by default, or minimize when the user preference is set. +@discardableResult +func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool { + guard let window else { return false } + + let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:] + if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() { + switch action { + case "minimize": + window.miniaturize(nil) + return true + case "none": + return false + case "maximize", "zoom": + window.zoom(nil) + return true + default: + break + } + } + + if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool, + miniaturizeOnDoubleClick { + window.miniaturize(nil) + return true + } + + window.zoom(nil) + return true +} + private var windowDragSuppressionDepthKey: UInt8 = 0 func beginWindowDragSuppression(window: NSWindow?) -> Int? { @@ -154,16 +188,20 @@ func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSVie if let topHit { let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView) + let topHitBelongsToTitlebarOverlay = topHit === superview || topHit.isDescendant(of: superview) let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit) #if DEBUG dlog( - "titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) passiveHost=\(isPassiveHostHit)" + "titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) inTitlebarOverlay=\(topHitBelongsToTitlebarOverlay) passiveHost=\(isPassiveHostHit)" ) #endif if ownsTopHit { return true } - if !isPassiveHostHit { + // Underlay content can transiently overlap titlebar space (notably browser + // chrome/webview layers). Only let top-hits block capture when they belong + // to this titlebar overlay stack. + if topHitBelongsToTitlebarOverlay && !isPassiveHostHit { return false } } @@ -238,11 +276,13 @@ struct WindowDragHandleView: NSViewRepresentable { #endif if event.clickCount >= 2 { - window?.zoom(nil) + let handled = performStandardTitlebarDoubleClick(window: window) #if DEBUG - dlog("titlebar.dragHandle.mouseDownDoubleClick zoom=1") + dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)") #endif - return + if handled { + return + } } guard !isWindowDragSuppressed(window: window) else { From 3d592fb09a14631d9686d5c02647945013e72cbf Mon Sep 17 00:00:00 2001 From: "Amar Sood (tekacs)" Date: Mon, 23 Feb 2026 23:45:57 -0500 Subject: [PATCH 086/136] Fix window title updates applying to wrong window TabManager.updateWindowTitle() used NSApp.keyWindow to find the target window, meaning any terminal title change (e.g. a spinner) would update whichever window happened to be focused, not the window that owns that TabManager. This corrupted the macOS Accessibility title attribute and caused visible title flapping in multi-window setups. Add a weak back-reference from TabManager to its owning NSWindow, set by AppDelegate.registerMainWindow(), and use it instead of keyWindow. --- Sources/AppDelegate.swift | 2 ++ Sources/TabManager.swift | 8 ++++++-- vendor/bonsplit | 1 - 3 files changed, 8 insertions(+), 3 deletions(-) delete mode 160000 vendor/bonsplit diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index debc0e09..dd7e7fac 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -478,6 +478,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarState: SidebarState, sidebarSelectionState: SidebarSelectionState ) { + tabManager.window = window + let key = ObjectIdentifier(window) if let existing = mainWindowContexts[key] { existing.window = window diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6c84d402..eb922785 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -309,6 +309,10 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { + /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). + /// Used to apply title updates to the correct window instead of NSApp.keyWindow. + weak var window: NSWindow? + @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false @@ -1161,8 +1165,8 @@ class TabManager: ObservableObject { private func updateWindowTitle(for tab: Workspace?) { let title = windowTitle(for: tab) - let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first - targetWindow?.title = title + guard let targetWindow = window else { return } + targetWindow.title = title } private func windowTitle(for tab: Workspace?) -> String { diff --git a/vendor/bonsplit b/vendor/bonsplit deleted file mode 160000 index dd20247b..00000000 --- a/vendor/bonsplit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dd20247b5536b4bd5b9b15cdf940e847daa1a18d From 1893fc4c7a426b5b2dde0b315f48aecf9d5c6714 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:09:36 -0800 Subject: [PATCH 087/136] Use native WebKit middle-click handling for browser links (#416) * Add middle-click debug logging for browser links * Handle browser middle-click via native WebKit actions * Fix flaky middle-click new-tab detection in browser --- Sources/Panels/BrowserPanel.swift | 165 ++++++++++++++++-- Sources/Panels/BrowserPanelView.swift | 8 - Sources/Panels/CmuxWebView.swift | 74 ++++++-- Sources/TabManager.swift | 1 - cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 108 ++++++++++++ 5 files changed, 314 insertions(+), 42 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 39805285..9427b67d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1857,15 +1857,43 @@ extension BrowserPanel { /// Open a link in a new browser surface in the same pane func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { - guard let tabManager = AppDelegate.shared?.tabManager, - let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), - let paneId = workspace.paneId(forPanelId: id) else { return } +#if DEBUG + dlog( + "browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " + + "bypass=\(bypassInsecureHTTPHostOnce ?? "nil")" + ) +#endif + guard let tabManager = AppDelegate.shared?.tabManager else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager") +#endif + return + } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing") +#endif + return + } + guard let paneId = workspace.paneId(forPanelId: id) else { +#if DEBUG + dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing") +#endif + return + } workspace.newBrowserSurface( inPane: paneId, url: url, focus: true, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) +#if DEBUG + dlog( + "browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))" + ) +#endif } /// Reload the current page @@ -2507,6 +2535,39 @@ private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { // MARK: - Navigation Delegate +func browserNavigationShouldOpenInNewTab( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + guard navigationType == .linkActivated || navigationType == .other else { + return false + } + + if modifierFlags.contains(.command) { + return true + } + if buttonNumber == 2 { + return true + } + // In some WebKit paths, middle-click arrives as buttonNumber=4. + // Recover intent when we just observed a local middle-click. + if buttonNumber == 4, hasRecentMiddleClickIntent { + return true + } + + // WebKit can omit buttonNumber for middle-click link activations. + if let currentEventType, + (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), + currentEventButtonNumber == 2 { + return true + } + return false +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? @@ -2645,16 +2706,41 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) + let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + ) +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + ) +#endif + if let url = navigationAction.request.url, navigationAction.targetFrame?.isMainFrame != false, shouldBlockInsecureHTTPNavigation?(url) == true { let intent: BrowserInsecureHTTPNavigationIntent - if navigationAction.navigationType == .linkActivated, - navigationAction.modifierFlags.contains(.command) { + if shouldOpenInNewTab { intent = .newTab } else { intent = .currentTab } +#if DEBUG + dlog( + "browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "url=\(url.absoluteString)" + ) +#endif handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) decisionHandler(.cancel) return @@ -2676,23 +2762,33 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - // target=_blank or window.open() — navigate in the current webview - if navigationAction.targetFrame == nil, - navigationAction.request.url != nil { - webView.load(navigationAction.request) - decisionHandler(.cancel) - return - } - - // Cmd+click on a regular link — open in a new tab - if navigationAction.navigationType == .linkActivated, - navigationAction.modifierFlags.contains(.command), + // Cmd+click and middle-click on regular links should always open in a new tab. + if shouldOpenInNewTab, let url = navigationAction.request.url { +#if DEBUG + dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)") +#endif openInNewTab?(url) decisionHandler(.cancel) return } + // target=_blank or window.open() without explicit new-tab intent — navigate in-place. + if navigationAction.targetFrame == nil, + navigationAction.request.url != nil { +#if DEBUG + let targetURL = navigationAction.request.url?.absoluteString ?? "nil" + dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)") +#endif + webView.load(navigationAction.request) + decisionHandler(.cancel) + return + } + +#if DEBUG + let targetURL = navigationAction.request.url?.absoluteString ?? "nil" + dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") +#endif decisionHandler(.allow) } @@ -2791,13 +2887,32 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } /// Returning nil tells WebKit not to open a new window. - /// Cmd+click opens in a new tab; regular target=_blank navigates in-place. + /// Cmd+click and middle-click open in a new tab; regular target=_blank navigates in-place. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { + let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView) + let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent + ) +#if DEBUG + let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" + let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" + let navType = String(describing: navigationAction.navigationType) + dlog( + "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + + "eventType=\(currentEventType) eventButton=\(currentEventButton) " + + "recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " + + "openInNewTab=\(shouldOpenInNewTab ? 1 : 0)" + ) +#endif if let url = navigationAction.request.url { if browserShouldOpenURLExternally(url) { let opened = NSWorkspace.shared.open(url) @@ -2811,11 +2926,23 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = - navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab + shouldOpenInNewTab ? .newTab : .currentTab +#if DEBUG + dlog( + "browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " + + "url=\(url.absoluteString)" + ) +#endif requestNavigation(navigationAction.request, intent) - } else if navigationAction.modifierFlags.contains(.command) { + } else if shouldOpenInNewTab { +#if DEBUG + dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)") +#endif openInNewTab?(url) } else { +#if DEBUG + dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)") +#endif webView.load(navigationAction.request) } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 71518994..82069f74 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -332,14 +332,6 @@ struct BrowserPanelView: View { #endif onRequestPanelFocus() } - .onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in - guard let webView = note.object as? CmuxWebView else { return false } - return webView === panel?.webView - }) { note in - if let url = note.userInfo?["url"] as? URL { - panel.openLinkInNewTab(url: url) - } - } .onAppear { UserDefaults.standard.register(defaults: [ BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 8f2a3a28..68a13282 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -8,6 +8,37 @@ import WebKit /// key equivalents first so app-level shortcuts continue to work when WebKit is /// the first responder. final class CmuxWebView: WKWebView { + // Some sites/WebKit paths report middle-click link activations as + // WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local + // middle-click so navigation delegates can recover intent reliably. + private struct MiddleClickIntent { + let webViewID: ObjectIdentifier + let uptime: TimeInterval + } + + private static var lastMiddleClickIntent: MiddleClickIntent? + private static let middleClickIntentMaxAge: TimeInterval = 0.8 + + static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool { + guard let webView = webView as? CmuxWebView else { return false } + guard let intent = lastMiddleClickIntent else { return false } + + let age = ProcessInfo.processInfo.systemUptime - intent.uptime + if age > middleClickIntentMaxAge { + lastMiddleClickIntent = nil + return false + } + + return intent.webViewID == ObjectIdentifier(webView) + } + + private static func recordMiddleClickIntent(for webView: CmuxWebView) { + lastMiddleClickIntent = MiddleClickIntent( + webViewID: ObjectIdentifier(webView), + uptime: ProcessInfo.processInfo.systemUptime + ) + } + private final class ContextMenuFallbackBox: NSObject { weak var target: AnyObject? let action: Selector? @@ -136,16 +167,33 @@ final class CmuxWebView: WKWebView { } } - // MARK: - Mouse back/forward buttons & middle-click + // MARK: - Mouse back/forward buttons override func otherMouseDown(with event: NSEvent) { + if event.buttonNumber == 2 { + Self.recordMiddleClickIntent(for: self) + } +#if DEBUG + let point = convert(event.locationInWindow, from: nil) + let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue + dlog( + "browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " + + "clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))" + ) +#endif // Button 3 = back, button 4 = forward (multi-button mice like Logitech). // Consume the event so WebKit doesn't handle it. switch event.buttonNumber { case 3: +#if DEBUG + dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)") +#endif goBack() return case 4: +#if DEBUG + dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)") +#endif goForward() return default: @@ -155,25 +203,23 @@ final class CmuxWebView: WKWebView { } override func otherMouseUp(with event: NSEvent) { - // Middle-click (button 2) on a link opens it in a new tab. if event.buttonNumber == 2 { - let point = convert(event.locationInWindow, from: nil) - findLinkAtPoint(point) { [weak self] url in - guard let self, let url else { return } - NotificationCenter.default.post( - name: .webViewMiddleClickedLink, - object: self, - userInfo: ["url": url] - ) - } - return + Self.recordMiddleClickIntent(for: self) } +#if DEBUG + let point = convert(event.locationInWindow, from: nil) + let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue + dlog( + "browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " + + "clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))" + ) +#endif super.otherMouseUp(with: event) } - /// Use JavaScript to find the nearest anchor element at the given view-local point. + /// Finds the nearest anchor element at a given view-local point. + /// Used as a context-menu download fallback. private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { - // WKWebView's coordinate system is flipped (origin top-left for web content). let flippedY = bounds.height - point.y let js = """ (() => { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0f2242d6..461b1abe 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3167,5 +3167,4 @@ extension Notification.Name { static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") - static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink") } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7f5dcb51..9ca9fb4d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1016,6 +1016,114 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { } } +final class BrowserNavigationNewTabDecisionTests: XCTestCase { + func testLinkActivatedCmdClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedPlainLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testOtherNavigationLeftClickStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testLinkActivatedButtonFourWithoutMiddleIntentStaysInCurrentTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: false + ) + ) + } + + func testLinkActivatedButtonFourWithRecentMiddleIntentOpensInNewTab() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 4, + hasRecentMiddleClickIntent: true + ) + ) + } + + func testLinkActivatedUsesCurrentEventFallbackForMiddleClick() { + XCTAssertTrue( + browserNavigationShouldOpenInNewTab( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testCurrentEventFallbackDoesNotAffectNonLinkNavigation() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [], + buttonNumber: 0, + currentEventType: .otherMouseUp, + currentEventButtonNumber: 2 + ) + ) + } + + func testNonLinkNavigationNeverForcesNewTab() { + XCTAssertFalse( + browserNavigationShouldOpenInNewTab( + navigationType: .reload, + modifierFlags: [.command], + buttonNumber: 2 + ) + ) + } +} + @MainActor final class BrowserJavaScriptDialogDelegateTests: XCTestCase { func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() { From 161e7538f27c5a1179cd0c3dcb5f676722c9f1da Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:34:25 -0800 Subject: [PATCH 088/136] Add new testimonials to wall of love (#427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add three new testimonials to wall of love - Norihiro Narayama (@northprint) — Japanese testimonial with subtle translation - Kishore Neelamegam (@indykish) - かたりん (@kataring) — Japanese testimonial with subtle translation * Add あさざ (@asaza_0928) testimonial to wall of love * Move あさざ testimonial to third position --- web/app/page.tsx | 3 +++ web/app/testimonials.tsx | 40 ++++++++++++++++++++++++++++++ web/public/avatars/asaza_0928.jpg | Bin 0 -> 41869 bytes web/public/avatars/indykish.jpg | Bin 0 -> 20720 bytes web/public/avatars/kataring.jpg | Bin 0 -> 12131 bytes web/public/avatars/northprint.jpg | Bin 0 -> 9221 bytes 6 files changed, 43 insertions(+) create mode 100644 web/public/avatars/asaza_0928.jpg create mode 100644 web/public/avatars/indykish.jpg create mode 100644 web/public/avatars/kataring.jpg create mode 100644 web/public/avatars/northprint.jpg diff --git a/web/app/page.tsx b/web/app/page.tsx index a15bc106..2093dd33 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -225,6 +225,9 @@ export default function Home() { "{t.text}" + {"translation" in t && t.translation && ( + — {t.translation} + )} {" "} {testimonial.text}

+ {"translation" in testimonial && testimonial.translation && ( +

+ {testimonial.translation} +

+ )}
); } diff --git a/web/public/avatars/asaza_0928.jpg b/web/public/avatars/asaza_0928.jpg new file mode 100644 index 0000000000000000000000000000000000000000..947179f80ace30bd3d150620219219e7a8115789 GIT binary patch literal 41869 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 literal 0 HcmV?d00001 diff --git a/web/public/avatars/indykish.jpg b/web/public/avatars/indykish.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0d7e5fab637bbd97a40a8325e79577918b2eeff5 GIT binary patch literal 20720 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 literal 0 HcmV?d00001 diff --git a/web/public/avatars/northprint.jpg b/web/public/avatars/northprint.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c48824404ee8ac0019d1f0051de475db16825a2 GIT binary patch literal 9221 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 T!X2*>uBhfkV0Su{GVy-^haZ7J literal 0 HcmV?d00001 From cd030732406d84aac71e75ec273d5be7e3cebc29 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:57:09 -0800 Subject: [PATCH 089/136] Fix tab drag blank state and preserve non-custom titles across window drops --- GhosttyTabs.xcodeproj/project.pbxproj | 56 +++++++------- Sources/GhosttyTerminalView.swift | 77 ++++++++++++++++--- Sources/Workspace.swift | 29 ++++++- Sources/WorkspaceContentView.swift | 17 +++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 45 +++++++++++ .../WorkspaceContentViewVisibilityTests.swift | 49 ++++++++++++ 6 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 cmuxTests/WorkspaceContentViewVisibilityTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 3448b298..82c0d154 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -198,10 +199,11 @@ D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -401,17 +403,18 @@ path = cmuxUITests; sourceTree = ""; }; - F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { - isa = PBXGroup; - children = ( - F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, - F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, - F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, - F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, - ); - path = cmuxTests; - sourceTree = ""; - }; + F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { + isa = PBXGroup; + children = ( + F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, + F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, + F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + ); + path = cmuxTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -601,17 +604,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F1000005A1B2C3D4E5F60718 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, - F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, - F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, - F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; + F1000005A1B2C3D4E5F60718 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, + F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, + F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; B9000006A1B2C3D4E5F60719 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 15068928..ecb5b7dc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4104,9 +4104,11 @@ final class GhosttySurfaceScrollView: NSView { isHidden = !visible #if DEBUG if wasVisible != visible { + let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)" + let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.visible", - suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)" + suffix: suffix ) } #endif @@ -4126,9 +4128,11 @@ final class GhosttySurfaceScrollView: NSView { isActive = active #if DEBUG if wasActive != active { + let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)" + let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.active", - suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)" + suffix: suffix ) } #endif @@ -4150,6 +4154,37 @@ final class GhosttySurfaceScrollView: NSView { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") } + + private func debugFirstResponderLabel() -> String { + guard let window, let firstResponder = window.firstResponder else { return "nil" } + if let view = firstResponder as? NSView { + if view === surfaceView { + return "surfaceView" + } + if view.isDescendant(of: surfaceView) { + return "surfaceDescendant" + } + return String(describing: type(of: view)) + } + return String(describing: type(of: firstResponder)) + } + + private func debugVisibilityStateSuffix(transition: String) -> String { + let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0 + let inWindow = window != nil ? 1 : 0 + let hasSuperview = superview != nil ? 1 : 0 + let hostHidden = isHidden ? 1 : 0 + let surfaceHidden = surfaceView.isHidden ? 1 : 0 + let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) + let frameText = String(format: "%.1fx%.1f", frame.width, frame.height) + let responder = debugFirstResponderLabel() + return + "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " + + "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " + + "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " + + "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)" + } #endif func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { @@ -5001,32 +5036,36 @@ struct GhosttyTerminalView: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator -#if DEBUG let previousDesiredIsActive = coordinator.desiredIsActive -#endif let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority + let desiredStateChanged = + previousDesiredIsActive != isActive || + previousDesiredIsVisibleInUI != isVisibleInUI || + previousDesiredPortalZPriority != portalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG - if previousDesiredIsActive != isActive || - previousDesiredIsVisibleInUI != isVisibleInUI || - previousDesiredPortalZPriority != portalZPriority { + if desiredStateChanged { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + - "active=\(isActive ? 1 : 0) z=\(portalZPriority)" + "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)" + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } } @@ -5114,6 +5153,16 @@ struct GhosttyTerminalView: NSViewRepresentable { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + + "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" + ) + } +#endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: coordinator.desiredIsVisibleInUI @@ -5137,6 +5186,16 @@ struct GhosttyTerminalView: NSViewRepresentable { } else { // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. // The currently bound host remains authoritative for immediate visible/active state. +#if DEBUG + if desiredStateChanged { + dlog( + "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + + "reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " + + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" + ) + } +#endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: isVisibleInUI diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 9ea80bd3..582f1d33 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2761,17 +2761,19 @@ extension Workspace: BonsplitDelegate { if isDetaching, let panel { let browserPanel = panel as? BrowserPanel + let cachedTitle = panelTitles[panelId] + let transferFallbackTitle = cachedTitle ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - cachedTitle: panelTitles[panelId], + cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId) ) @@ -2859,14 +2861,35 @@ extension Workspace: BonsplitDelegate { } debugLastDidMoveTabTimestamp = now debugDidMoveTabEventCount += 1 - let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" + let movedPanelId = panelIdFromSurfaceId(tab.id) + let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown" + let selectedBefore = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" dlog( "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) + dlog( + "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" + ) #endif applyTabSelection(tabId: tab.id, inPane: destination) +#if DEBUG + let selectedAfter = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" + let movedPanelFocused = (movedPanelId != nil && movedPanelId == focusedPanelId) ? 1 : 0 + dlog( + "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + + "movedFocused=\(movedPanelFocused)" + ) +#endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 392f9986..3e058a47 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -19,6 +19,17 @@ struct WorkspaceContentView: View { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore + static func panelVisibleInUI( + isWorkspaceVisible: Bool, + isSelectedInPane: Bool, + isFocused: Bool + ) -> Bool { + guard isWorkspaceVisible else { return false } + // During pane/tab reparenting, Bonsplit can transiently report selected=false + // for the currently focused panel. Keep focused content visible to avoid blank frames. + return isSelectedInPane || isFocused + } + var body: some View { let appearance = PanelAppearance.fromConfig(config) let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || @@ -47,7 +58,11 @@ struct WorkspaceContentView: View { if let panel = workspace.panel(for: tab.id) { let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id - let isVisibleInUI = isWorkspaceVisible && isSelectedInPane + let isVisibleInUI = Self.panelVisibleInUI( + isWorkspaceVisible: isWorkspaceVisible, + isSelectedInPane: isSelectedInPane, + isFocused: isFocused + ) let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7f5dcb51..eb305a1d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2973,6 +2973,51 @@ final class WorkspacePanelGitBranchTests: XCTestCase { #endif } + func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() { + let source = Workspace() + guard let panelId = source.focusedPanelId else { + XCTFail("Expected source focused panel") + return + } + + XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title")) + + guard let detached = source.detachSurface(panelId: panelId) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.cachedTitle, "detached-runtime-title") + XCTAssertNil(detached.customTitle) + XCTAssertEqual( + detached.title, + "detached-runtime-title", + "Detached transfer should carry the cached non-custom title" + ) + + let destination = Workspace() + guard let destinationPane = destination.bonsplitController.allPaneIds.first else { + XCTFail("Expected destination pane") + return + } + + let attachedPanelId = destination.attachDetachedSurface( + detached, + inPane: destinationPane, + focus: false + ) + XCTAssertEqual(attachedPanelId, panelId) + XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title") + + guard let attachedTabId = destination.surfaceIdFromPanelId(panelId), + let attachedTab = destination.bonsplitController.tab(attachedTabId) else { + XCTFail("Expected attached tab mapping") + return + } + XCTAssertEqual(attachedTab.title, "detached-runtime-title") + XCTAssertFalse(attachedTab.hasCustomTitle) + } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { diff --git a/cmuxTests/WorkspaceContentViewVisibilityTests.swift b/cmuxTests/WorkspaceContentViewVisibilityTests.swift new file mode 100644 index 00000000..6e8d62e3 --- /dev/null +++ b/cmuxTests/WorkspaceContentViewVisibilityTests.swift @@ -0,0 +1,49 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class WorkspaceContentViewVisibilityTests: XCTestCase { + func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: false, + isSelectedInPane: true, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForSelectedPanel() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: true, + isFocused: false + ) + ) + } + + func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() { + XCTAssertTrue( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: true + ) + ) + } + + func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() { + XCTAssertFalse( + WorkspaceContentView.panelVisibleInUI( + isWorkspaceVisible: true, + isSelectedInPane: false, + isFocused: false + ) + ) + } +} From 7d59e550bc81b8da692bd596253a3cee485efd0a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Feb 2026 23:57:13 -0800 Subject: [PATCH 090/136] Fix browser Return/Enter routing and add enter trace logs --- Sources/AppDelegate.swift | 65 +++++++++- Sources/Panels/CmuxWebView.swift | 111 +++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 36 ++++++ 3 files changed, 206 insertions(+), 6 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index befa7e43..20c3a27a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2660,6 +2660,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if event.type == .keyDown { #if DEBUG + let isEnterKey = event.keyCode == 36 || event.keyCode == 76 if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" || UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")), event.timestamp > 0 { @@ -2671,18 +2672,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent dlog( "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" ) + if isEnterKey { + dlog( + "enter.trace stage=app.monitor.pre event=\(NSWindow.keyDescription(event)) " + + "fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + ) + } if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } #endif if self.handleCustomShortcut(event: event) { #if DEBUG + if isEnterKey { + dlog( + "enter.trace stage=app.monitor.consume event=\(NSWindow.keyDescription(event)) " + + "reason=handleCustomShortcut" + ) + } dlog(" → consumed by handleCustomShortcut") DebugEventLog.shared.dump() #endif return nil // Consume the event } #if DEBUG + if isEnterKey { + dlog( + "enter.trace stage=app.monitor.pass event=\(NSWindow.keyDescription(event)) " + + "reason=handleCustomShortcutReturnedFalse" + ) + } DebugEventLog.shared.dump() #endif return event // Pass through @@ -3802,7 +3821,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// through the same app-level shortcut handler used by the local key monitor. @discardableResult func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { - handleCustomShortcut(event: event) + let consumed = handleCustomShortcut(event: event) +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "enter.trace stage=app.browserSurfaceKeyEquivalent event=\(NSWindow.keyDescription(event)) " + + "consumed=\(consumed ? 1 : 0) fr=\(frType) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + ) + } +#endif + return consumed } #if DEBUG @@ -5220,9 +5250,19 @@ private extension NSWindow { } @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { + let isEnterKey = event.keyCode == 36 || event.keyCode == 76 #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") + if isEnterKey { + let frGhostty = cmuxOwningGhosttyView(for: self.firstResponder) != nil + let frWeb = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0) } != nil + dlog( + "enter.trace stage=window.performKeyEquivalent.start event=\(Self.keyDescription(event)) " + + "fr=\(frType) frGhostty=\(frGhostty ? 1 : 0) frWeb=\(frWeb ? 1 : 0) " + + "win=\(self.windowNumber)" + ) + } #endif // When the terminal surface is the first responder, prevent SwiftUI's @@ -5253,6 +5293,12 @@ private extension NSWindow { let result = ghosttyView.performKeyEquivalent(with: event) #if DEBUG dlog(" → ghostty direct: \(result)") + if isEnterKey { + dlog( + "enter.trace stage=window.performKeyEquivalent.ghosttyDirect " + + "event=\(Self.keyDescription(event)) consumed=\(result ? 1 : 0)" + ) + } #endif return result } @@ -5274,7 +5320,16 @@ private extension NSWindow { } } - if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { + let consumedByBrowserSurface = AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true +#if DEBUG + if isEnterKey { + dlog( + "enter.trace stage=window.performKeyEquivalent.browserSurface event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByBrowserSurface ? 1 : 0)" + ) + } +#endif + if consumedByBrowserSurface { #if DEBUG dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") #endif @@ -5313,6 +5368,12 @@ private extension NSWindow { let result = cmux_performKeyEquivalent(with: event) #if DEBUG if result { dlog(" → consumed by original performKeyEquivalent") } + if isEnterKey { + dlog( + "enter.trace stage=window.performKeyEquivalent.original event=\(Self.keyDescription(event)) " + + "consumed=\(result ? 1 : 0)" + ) + } #endif return result } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 68a13282..788e417e 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -63,6 +63,72 @@ final class CmuxWebView: WKWebView { } var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } +#if DEBUG + private func debugKeyDescription(_ event: NSEvent) -> String { + var parts: [String] = [] + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command) { parts.append("Cmd") } + if flags.contains(.shift) { parts.append("Shift") } + if flags.contains(.option) { parts.append("Opt") } + if flags.contains(.control) { parts.append("Ctrl") } + let chars = event.charactersIgnoringModifiers ?? "?" + parts.append("'\(chars)'(\(event.keyCode))") + return parts.joined(separator: "+") + } + + private func debugEnterTrace( + stage: String, + event: NSEvent, + consumed: Bool? = nil, + note: String? = nil + ) { + let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let host = url?.host ?? "nil" + var line = + "enter.trace stage=\(stage) web=\(ObjectIdentifier(self)) " + + "event=\(debugKeyDescription(event)) fr=\(firstResponderType) " + + "win=\(window?.windowNumber ?? -1) host=\(host)" + if let consumed { + line += " consumed=\(consumed ? 1 : 0)" + } + if let note { + line += " note=\(note)" + } + dlog(line) + } + + private func debugLogActiveElementForEnter(stage: String) { + let js = """ + (() => { + const el = document.activeElement; + if (!el) return 'none'; + const tag = (el.tagName || '').toLowerCase(); + const id = el.id || '-'; + const name = el.getAttribute('name') || '-'; + const type = el.getAttribute('type') || '-'; + return `${tag}|${id}|${name}|${type}`; + })(); + """ + evaluateJavaScript(js) { [weak self] result, error in + guard let self else { return } + let activeSummary: String + if let error { + activeSummary = "error=\(error.localizedDescription)" + } else if let text = result as? String { + activeSummary = text + } else if let result { + activeSummary = String(describing: result) + } else { + activeSummary = "nil" + } + dlog( + "enter.trace stage=\(stage).dom web=\(ObjectIdentifier(self)) " + + "active=\(activeSummary)" + ) + } + } +#endif + override func becomeFirstResponder() -> Bool { guard allowsFirstResponderAcquisitionEffective else { #if DEBUG @@ -113,13 +179,27 @@ final class CmuxWebView: WKWebView { } override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not - // route it through app/menu key equivalents, which can trigger unintended actions. - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 { + if event.keyCode == 36 || event.keyCode == 76 { + // Always bypass app/menu key-equivalent routing for Return/Enter so WebKit + // receives the keyDown path used by form submission handlers. +#if DEBUG + debugEnterTrace( + stage: "web.performKeyEquivalent.bypass", + event: event, + consumed: false, + note: "returnFalseForEnter" + ) +#endif return false } + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + // Menu/app shortcut routing is only needed for Command equivalents + // (New Tab, Close Tab, tab switching, split commands, etc). + guard flags.contains(.command) else { + return super.performKeyEquivalent(with: event) + } + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true @@ -135,14 +215,37 @@ final class CmuxWebView: WKWebView { } override func keyDown(with event: NSEvent) { +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + debugEnterTrace(stage: "web.keyDown.pre", event: event, note: "beforeSuper") + debugLogActiveElementForEnter(stage: "web.keyDown.pre") + } +#endif + // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + debugEnterTrace( + stage: "web.keyDown.commandConsumed", + event: event, + consumed: true, + note: "handleBrowserSurfaceKeyEquivalent" + ) + } +#endif return } super.keyDown(with: event) +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + debugEnterTrace(stage: "web.keyDown.post", event: event, note: "afterSuper") + debugLogActiveElementForEnter(stage: "web.keyDown.post") + } +#endif } // MARK: - Focus on click diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9ca9fb4d..a1ad944f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -149,6 +149,42 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertTrue(spy.invoked) } + func testReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testCmdReturnDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + + func testKeypadEnterDoesNotRouteToMainMenuWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: []) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [], keyCode: 76) // kVK_ANSI_KeypadEnter + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + @MainActor func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { _ = NSApplication.shared From e510bf2d1721d2141a892b32123aaefb55925b1f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 00:09:53 -0800 Subject: [PATCH 091/136] Remove temporary Enter tracing instrumentation --- Sources/AppDelegate.swift | 65 +-------------------- Sources/Panels/CmuxWebView.swift | 97 -------------------------------- 2 files changed, 2 insertions(+), 160 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 20c3a27a..befa7e43 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2660,7 +2660,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if event.type == .keyDown { #if DEBUG - let isEnterKey = event.keyCode == 36 || event.keyCode == 76 if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" || UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")), event.timestamp > 0 { @@ -2672,36 +2671,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent dlog( "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" ) - if isEnterKey { - dlog( - "enter.trace stage=app.monitor.pre event=\(NSWindow.keyDescription(event)) " + - "fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" - ) - } if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } #endif if self.handleCustomShortcut(event: event) { #if DEBUG - if isEnterKey { - dlog( - "enter.trace stage=app.monitor.consume event=\(NSWindow.keyDescription(event)) " + - "reason=handleCustomShortcut" - ) - } dlog(" → consumed by handleCustomShortcut") DebugEventLog.shared.dump() #endif return nil // Consume the event } #if DEBUG - if isEnterKey { - dlog( - "enter.trace stage=app.monitor.pass event=\(NSWindow.keyDescription(event)) " + - "reason=handleCustomShortcutReturnedFalse" - ) - } DebugEventLog.shared.dump() #endif return event // Pass through @@ -3821,18 +3802,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// through the same app-level shortcut handler used by the local key monitor. @discardableResult func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { - let consumed = handleCustomShortcut(event: event) -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "enter.trace stage=app.browserSurfaceKeyEquivalent event=\(NSWindow.keyDescription(event)) " + - "consumed=\(consumed ? 1 : 0) fr=\(frType) " + - "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" - ) - } -#endif - return consumed + handleCustomShortcut(event: event) } #if DEBUG @@ -5250,19 +5220,9 @@ private extension NSWindow { } @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { - let isEnterKey = event.keyCode == 36 || event.keyCode == 76 #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") - if isEnterKey { - let frGhostty = cmuxOwningGhosttyView(for: self.firstResponder) != nil - let frWeb = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0) } != nil - dlog( - "enter.trace stage=window.performKeyEquivalent.start event=\(Self.keyDescription(event)) " + - "fr=\(frType) frGhostty=\(frGhostty ? 1 : 0) frWeb=\(frWeb ? 1 : 0) " + - "win=\(self.windowNumber)" - ) - } #endif // When the terminal surface is the first responder, prevent SwiftUI's @@ -5293,12 +5253,6 @@ private extension NSWindow { let result = ghosttyView.performKeyEquivalent(with: event) #if DEBUG dlog(" → ghostty direct: \(result)") - if isEnterKey { - dlog( - "enter.trace stage=window.performKeyEquivalent.ghosttyDirect " + - "event=\(Self.keyDescription(event)) consumed=\(result ? 1 : 0)" - ) - } #endif return result } @@ -5320,16 +5274,7 @@ private extension NSWindow { } } - let consumedByBrowserSurface = AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true -#if DEBUG - if isEnterKey { - dlog( - "enter.trace stage=window.performKeyEquivalent.browserSurface event=\(Self.keyDescription(event)) " + - "consumed=\(consumedByBrowserSurface ? 1 : 0)" - ) - } -#endif - if consumedByBrowserSurface { + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { #if DEBUG dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") #endif @@ -5368,12 +5313,6 @@ private extension NSWindow { let result = cmux_performKeyEquivalent(with: event) #if DEBUG if result { dlog(" → consumed by original performKeyEquivalent") } - if isEnterKey { - dlog( - "enter.trace stage=window.performKeyEquivalent.original event=\(Self.keyDescription(event)) " + - "consumed=\(result ? 1 : 0)" - ) - } #endif return result } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 788e417e..bcd77ed2 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -63,72 +63,6 @@ final class CmuxWebView: WKWebView { } var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } -#if DEBUG - private func debugKeyDescription(_ event: NSEvent) -> String { - var parts: [String] = [] - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command) { parts.append("Cmd") } - if flags.contains(.shift) { parts.append("Shift") } - if flags.contains(.option) { parts.append("Opt") } - if flags.contains(.control) { parts.append("Ctrl") } - let chars = event.charactersIgnoringModifiers ?? "?" - parts.append("'\(chars)'(\(event.keyCode))") - return parts.joined(separator: "+") - } - - private func debugEnterTrace( - stage: String, - event: NSEvent, - consumed: Bool? = nil, - note: String? = nil - ) { - let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let host = url?.host ?? "nil" - var line = - "enter.trace stage=\(stage) web=\(ObjectIdentifier(self)) " + - "event=\(debugKeyDescription(event)) fr=\(firstResponderType) " + - "win=\(window?.windowNumber ?? -1) host=\(host)" - if let consumed { - line += " consumed=\(consumed ? 1 : 0)" - } - if let note { - line += " note=\(note)" - } - dlog(line) - } - - private func debugLogActiveElementForEnter(stage: String) { - let js = """ - (() => { - const el = document.activeElement; - if (!el) return 'none'; - const tag = (el.tagName || '').toLowerCase(); - const id = el.id || '-'; - const name = el.getAttribute('name') || '-'; - const type = el.getAttribute('type') || '-'; - return `${tag}|${id}|${name}|${type}`; - })(); - """ - evaluateJavaScript(js) { [weak self] result, error in - guard let self else { return } - let activeSummary: String - if let error { - activeSummary = "error=\(error.localizedDescription)" - } else if let text = result as? String { - activeSummary = text - } else if let result { - activeSummary = String(describing: result) - } else { - activeSummary = "nil" - } - dlog( - "enter.trace stage=\(stage).dom web=\(ObjectIdentifier(self)) " + - "active=\(activeSummary)" - ) - } - } -#endif - override func becomeFirstResponder() -> Bool { guard allowsFirstResponderAcquisitionEffective else { #if DEBUG @@ -182,14 +116,6 @@ final class CmuxWebView: WKWebView { if event.keyCode == 36 || event.keyCode == 76 { // Always bypass app/menu key-equivalent routing for Return/Enter so WebKit // receives the keyDown path used by form submission handlers. -#if DEBUG - debugEnterTrace( - stage: "web.performKeyEquivalent.bypass", - event: event, - consumed: false, - note: "returnFalseForEnter" - ) -#endif return false } @@ -215,37 +141,14 @@ final class CmuxWebView: WKWebView { } override func keyDown(with event: NSEvent) { -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - debugEnterTrace(stage: "web.keyDown.pre", event: event, note: "beforeSuper") - debugLogActiveElementForEnter(stage: "web.keyDown.pre") - } -#endif - // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - debugEnterTrace( - stage: "web.keyDown.commandConsumed", - event: event, - consumed: true, - note: "handleBrowserSurfaceKeyEquivalent" - ) - } -#endif return } super.keyDown(with: event) -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - debugEnterTrace(stage: "web.keyDown.post", event: event, note: "afterSuper") - debugLogActiveElementForEnter(stage: "web.keyDown.post") - } -#endif } // MARK: - Focus on click From 5dc2d8d800a3ab3bf1cfd6533a7148f284abb61f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 13:54:52 -0800 Subject: [PATCH 092/136] wip --- Sources/AppDelegate.swift | 35 +++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 38 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1f107216..442313d7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -286,6 +286,14 @@ func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { return normalizedFlags == [] || normalizedFlags == [.shift] } +func shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool +) -> Bool { + guard firstResponderIsBrowser else { return false } + return keyCode == 36 || keyCode == 76 +} + func commandPaletteSelectionDeltaForKeyboardNavigation( flags: NSEvent.ModifierFlags, chars: String, @@ -5279,6 +5287,7 @@ enum MenuBarIconRenderer { private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent? private var cmuxFirstResponderGuardHitViewOverride: NSView? #endif +private var cmuxBrowserReturnForwardingDepth = 0 private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { @@ -5396,6 +5405,7 @@ private extension NSWindow { // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0) } if let ghosttyView = firstResponderGhosttyView { // If the IME is composing, don't intercept key events — let them flow // through normal AppKit event dispatch so the input method can process them. @@ -5429,6 +5439,31 @@ private extension NSWindow { } } + // Web forms rely on Return/Enter flowing through keyDown. If the original + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. + if shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil + ) { + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + if cmuxBrowserReturnForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Return/Enter reentry; using normal dispatch") +#endif + return false + } + cmuxBrowserReturnForwardingDepth += 1 + defer { cmuxBrowserReturnForwardingDepth = max(0, cmuxBrowserReturnForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Return/Enter routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true + } + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { #if DEBUG dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ca812481..110c8d4d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1520,6 +1520,44 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { } } +final class BrowserReturnKeyDownRoutingTests: XCTestCase { + func testRoutesForReturnWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: true + ) + ) + } + + func testRoutesForKeypadEnterWhenBrowserFirstResponder() { + XCTAssertTrue( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 76, + firstResponderIsBrowser: true + ) + ) + } + + func testDoesNotRouteForNonEnterKey() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 13, + firstResponderIsBrowser: true + ) + ) + } + + func testDoesNotRouteWhenFirstResponderIsNotBrowser() { + XCTAssertFalse( + shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: 36, + firstResponderIsBrowser: false + ) + ) + } +} + final class BrowserZoomShortcutActionTests: XCTestCase { func testZoomInSupportsEqualsAndPlusVariants() { XCTAssertEqual( From 6c17bbf64edb88e4667428b52f7bf26b32cb2710 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:58:32 -0800 Subject: [PATCH 093/136] Restore vendor/bonsplit submodule pointer --- vendor/bonsplit | 1 + 1 file changed, 1 insertion(+) create mode 160000 vendor/bonsplit diff --git a/vendor/bonsplit b/vendor/bonsplit new file mode 160000 index 00000000..f24ba922 --- /dev/null +++ b/vendor/bonsplit @@ -0,0 +1 @@ +Subproject commit f24ba9222651ecc170869662eec9a5880404a82c From c56ef6775016530c3a0fffca862cdff12381564a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:05:45 -0800 Subject: [PATCH 094/136] Fix browser chrome contrast for mixed light/dark themes --- Sources/Panels/BrowserPanelView.swift | 22 ++- ...test_browser_chrome_contrast_regression.py | 126 ++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/test_browser_chrome_contrast_regression.py diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 82069f74..9234e84a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -178,6 +178,17 @@ func resolvedBrowserChromeBackgroundColor( } } +func resolvedBrowserChromeColorScheme( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> ColorScheme { + let backgroundColor = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackgroundColor + ) + return backgroundColor.isLightColor ? .light : .dark +} + func resolvedBrowserOmnibarPillBackgroundColor( for colorScheme: ColorScheme, themeBackgroundColor: NSColor @@ -274,9 +285,16 @@ struct BrowserPanelView: View { ) } + private var browserChromeColorScheme: ColorScheme { + resolvedBrowserChromeColorScheme( + for: colorScheme, + themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + ) + } + private var omnibarPillBackgroundColor: NSColor { resolvedBrowserOmnibarPillBackgroundColor( - for: colorScheme, + for: browserChromeColorScheme, themeBackgroundColor: browserChromeBackgroundColor ) } @@ -312,6 +330,7 @@ struct BrowserPanelView: View { .frame(width: omnibarPillFrame.width) .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3) .zIndex(1000) + .environment(\.colorScheme, browserChromeColorScheme) } } .coordinateSpace(name: "BrowserPanelViewSpace") @@ -458,6 +477,7 @@ struct BrowserPanelView: View { .background(Color(nsColor: browserChromeBackgroundColor)) // Keep the omnibar stack above WKWebView so the suggestions popup is visible. .zIndex(1) + .environment(\.colorScheme, browserChromeColorScheme) } private var addressBarButtonBar: some View { diff --git a/tests/test_browser_chrome_contrast_regression.py b/tests/test_browser_chrome_contrast_regression.py new file mode 100644 index 00000000..a2552f2f --- /dev/null +++ b/tests/test_browser_chrome_contrast_regression.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Static regression guards for browser chrome contrast in mixed theme setups.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" + source = view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + try: + browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View") + except ValueError as error: + failures.append(str(error)) + browser_panel_view_block = "" + + try: + resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(") + except ValueError as error: + failures.append(str(error)) + resolver_block = "" + + if resolver_block: + if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block: + failures.append( + "resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme" + ) + + try: + chrome_scheme_block = extract_block( + browser_panel_view_block, + "private var browserChromeColorScheme: ColorScheme", + ) + except ValueError as error: + failures.append(str(error)) + chrome_scheme_block = "" + + if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block: + failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme") + + try: + omnibar_background_block = extract_block( + browser_panel_view_block, + "private var omnibarPillBackgroundColor: NSColor", + ) + except ValueError as error: + failures.append(str(error)) + omnibar_background_block = "" + + if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block: + failures.append("omnibar pill background must use browserChromeColorScheme") + + try: + address_bar_block = extract_block( + browser_panel_view_block, + "private var addressBar: some View", + ) + except ValueError as error: + failures.append(str(error)) + address_bar_block = "" + + if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block: + failures.append("addressBar must apply browserChromeColorScheme via environment") + + try: + body_block = extract_block(browser_panel_view_block, "var body: some View") + except ValueError as error: + failures.append(str(error)) + body_block = "" + + if body_block: + if "OmnibarSuggestionsView(" not in body_block: + failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body") + elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block: + failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment") + + if failures: + print("FAIL: browser chrome contrast regression guards failed") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS: browser chrome contrast regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 233876018640fae167e2f422048a0f39ec013479 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:12:15 -0800 Subject: [PATCH 095/136] Set selected sidebar workspace colors and white text --- Sources/ContentView.swift | 49 ++++++++++++++----- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 38 ++++++++++++++ 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3a21ed9d..752ffa29 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -38,6 +38,30 @@ func sidebarActiveForegroundNSColor( return baseColor.withAlphaComponent(clampedOpacity) } +func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { + switch colorScheme { + case .dark: + return NSColor( + srgbRed: 63.0 / 255.0, + green: 142.0 / 255.0, + blue: 252.0 / 255.0, + alpha: 1.0 + ) + default: + return NSColor( + srgbRed: 62.0 / 255.0, + green: 133.0 / 255.0, + blue: 252.0 / 255.0, + alpha: 1.0 + ) + } +} + +func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { + let clampedOpacity = max(0, min(opacity, 1)) + return NSColor.white.withAlphaComponent(clampedOpacity) +} + struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -5974,12 +5998,14 @@ private struct TabItemView: View { } private var activePrimaryTextColor: Color { - usesInvertedActiveForeground ? Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0)) : .primary + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + : .primary } private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { usesInvertedActiveForeground - ? Color(nsColor: sidebarActiveForegroundNSColor(opacity: CGFloat(opacity))) + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) : .secondary } @@ -6465,16 +6491,15 @@ private struct TabItemView: View { private var backgroundColor: Color { switch activeTabIndicatorStyle { case .leftRail: - if isActive { return Color.accentColor } + if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } if isMultiSelected { return Color.accentColor.opacity(0.25) } return Color.clear case .solidFill: + if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } if let custom = resolvedCustomTabColor { - if isActive { return custom } if isMultiSelected { return custom.opacity(0.35) } return custom.opacity(0.7) } - if isActive { return Color.accentColor } if isMultiSelected { return Color.accentColor.opacity(0.25) } return Color.clear } @@ -6789,15 +6814,15 @@ private struct TabItemView: View { if isActive { switch level { case .info: - return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.5)) + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) case .progress: - return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.8)) + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) case .success: - return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.9)) + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) case .warning: - return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.9)) + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) case .error: - return Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.9)) + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) } } switch level { @@ -6934,11 +6959,11 @@ private struct SidebarStatusPillsRow: View { } private var activePrimaryTextColor: Color { - Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.8)) + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) } private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarActiveForegroundNSColor(opacity: 0.65)) + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) } private var statusText: String { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 110c8d4d..7f742558 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -843,6 +843,44 @@ final class SidebarActiveForegroundColorTests: XCTestCase { } } +final class SidebarSelectedWorkspaceColorTests: XCTestCase { + func testLightModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .light).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 62.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 133.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 252.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testDarkModeUsesConfiguredSelectedWorkspaceBackgroundColor() { + guard let color = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 63.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 142.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 252.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) + } + + func testSelectedWorkspaceForegroundAlwaysUsesWhiteWithRequestedOpacity() { + guard let color = sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) + XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) + } +} + final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut From e76de71f3e12f35e623411a6c0f0e0d868a01e71 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 14:18:58 -0800 Subject: [PATCH 096/136] Fix sidebar titlebar drag and double-click passthrough --- Sources/BrowserWindowPortal.swift | 8 ++++++-- Sources/ContentView.swift | 27 +++------------------------ 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 448f0e46..1a5ea166 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -133,9 +133,13 @@ final class WindowBrowserHostView: NSView { private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool { guard let window else { return false } // Window-level portal hosts sit above SwiftUI content. Never intercept - // hits that land in the native titlebar region. + // hits that land in native titlebar space or the custom titlebar strip + // we reserve directly under it for window drag/double-click behaviors. let windowPoint = convert(point, to: nil) - return windowPoint.y >= (window.contentLayoutRect.maxY - 0.5) + let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight)) + let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5 + return windowPoint.y >= interactionBandMinY } private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3a21ed9d..8f3e6157 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5241,9 +5241,9 @@ struct VerticalTabsSidebar: View { .allowsHitTesting(false) } .overlay(alignment: .top) { - // Double-click the sidebar title-bar area to trigger the - // standard macOS titlebar action (zoom/minimize). - DoubleClickZoomView() + // Match native titlebar behavior in the sidebar top strip: + // drag-to-move and double-click action (zoom/minimize). + WindowDragHandleView() .frame(height: trafficLightPadding) } .background(Color.clear) @@ -7479,27 +7479,6 @@ private struct SidebarTabDropDelegate: DropDelegate { } } -/// AppKit-level double-click handler for the sidebar title-bar area. -/// Uses NSView hit-testing so it isn't swallowed by the SwiftUI ScrollView underneath. -private struct DoubleClickZoomView: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - DoubleClickZoomNSView() - } - - func updateNSView(_ nsView: NSView, context: Context) {} - - private final class DoubleClickZoomNSView: NSView { - override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } - override func mouseDown(with event: NSEvent) { - if event.clickCount == 2, performStandardTitlebarDoubleClick(window: window) { - return - } - super.mouseDown(with: event) - } - } -} - private struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void From 98cf07ce2a78e2740cf7bab37a70e10464520e8f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 14:20:14 -0800 Subject: [PATCH 097/136] Stabilize terminal render recovery after split topology churn --- Sources/GhosttyTerminalView.swift | 8 ++++ Sources/Workspace.swift | 66 +++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ecb5b7dc..5dd0385c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1914,6 +1914,14 @@ final class TerminalSurface: Identifiable, ObservableObject { return } + // Reassert display id on topology churn (split close/reparent) before forcing a refresh. + // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active + // but callbacks have not resumed for the current display. + if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, + displayID != 0 { + ghostty_surface_set_display_id(surface, displayID) + } + view.forceRefreshSurface() ghostty_surface_refresh(surface) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 582f1d33..272bd086 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -577,6 +577,7 @@ final class Workspace: Identifiable, ObservableObject { private var debugDidMoveTabEventCount: UInt64 = 0 #endif private var geometryReconcileScheduled = false + private var geometryReconcileNeedsRerun = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -2241,18 +2242,67 @@ final class Workspace: Identifiable, ObservableObject { /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. + private func reconcileTerminalGeometryPass() -> Bool { + var needsFollowUpPass = false + + // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + } + + for panel in panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + let hostedView = terminalPanel.hostedView + let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 + let hasSurface = terminalPanel.surface.surface != nil + let isAttached = hostedView.window != nil && hostedView.superview != nil + + // Split close/reparent churn can transiently detach a surviving terminal view. + // Force one SwiftUI representable update so the portal binding reattaches it. + if !isAttached || !hasUsableBounds || !hasSurface { + terminalPanel.requestViewReattach() + needsFollowUpPass = true + } + + hostedView.reconcileGeometryNow() + terminalPanel.surface.forceRefresh() + } + + return needsFollowUpPass + } + + private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { + guard remainingPasses > 0 else { + geometryReconcileScheduled = false + geometryReconcileNeedsRerun = false + return + } + + let needsFollowUpPass = reconcileTerminalGeometryPass() + let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass + + if shouldRunAgain, remainingPasses > 1 { + geometryReconcileNeedsRerun = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) + } + return + } + + geometryReconcileScheduled = false + geometryReconcileNeedsRerun = false + } + private func scheduleTerminalGeometryReconcile() { - guard !geometryReconcileScheduled else { return } + guard !geometryReconcileScheduled else { + geometryReconcileNeedsRerun = true + return + } geometryReconcileScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } - self.geometryReconcileScheduled = false - - for panel in self.panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.surface.forceRefresh() - } + self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) } } From aeda5f827de8118d843bd72a0f3dc9e02b096ab0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:22:58 -0800 Subject: [PATCH 098/136] Adopt custom blue accent across active UI states --- Sources/ContentView.swift | 48 +++++++++++++------ Sources/GhosttyTerminalView.swift | 4 +- Sources/NotificationsPage.swift | 4 +- Sources/Panels/BrowserPanelView.swift | 8 ++-- Sources/Update/UpdateTitlebarAccessory.swift | 6 +-- Sources/Update/UpdateViewModel.swift | 4 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 12 ++--- 7 files changed, 53 insertions(+), 33 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 752ffa29..36fca195 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -38,25 +38,45 @@ func sidebarActiveForegroundNSColor( return baseColor.withAlphaComponent(clampedOpacity) } -func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { +func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor { switch colorScheme { case .dark: return NSColor( - srgbRed: 63.0 / 255.0, - green: 142.0 / 255.0, - blue: 252.0 / 255.0, + srgbRed: 0, + green: 145.0 / 255.0, + blue: 1.0, alpha: 1.0 ) default: return NSColor( - srgbRed: 62.0 / 255.0, - green: 133.0 / 255.0, - blue: 252.0 / 255.0, + srgbRed: 0, + green: 136.0 / 255.0, + blue: 1.0, alpha: 1.0 ) } } +func cmuxAccentNSColor(for appAppearance: NSAppearance?) -> NSColor { + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + let scheme: ColorScheme = (bestMatch == .darkAqua) ? .dark : .light + return cmuxAccentNSColor(for: scheme) +} + +func cmuxAccentNSColor() -> NSColor { + NSColor(name: nil) { appearance in + cmuxAccentNSColor(for: appearance) + } +} + +func cmuxAccentColor() -> Color { + Color(nsColor: cmuxAccentNSColor()) +} + +func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { + cmuxAccentNSColor(for: colorScheme) +} + func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) return NSColor.white.withAlphaComponent(clampedOpacity) @@ -2583,7 +2603,7 @@ struct ContentView: View { let isSelected = index == selectedIndex let isHovered = commandPaletteHoveredResultIndex == index let rowBackground: Color = isSelected - ? Color.accentColor.opacity(0.12) + ? cmuxAccentColor().opacity(0.12) : (isHovered ? Color.primary.opacity(0.08) : .clear) Button { @@ -5903,7 +5923,7 @@ private struct SidebarEmptyArea: View { .overlay(alignment: .top) { if shouldShowTopDropIndicator { Rectangle() - .fill(Color.accentColor) + .fill(cmuxAccentColor()) .frame(height: 2) .padding(.horizontal, 8) .offset(y: -(rowSpacing / 2)) @@ -6010,7 +6030,7 @@ private struct TabItemView: View { } private var activeUnreadBadgeFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.25) : Color.accentColor + usesInvertedActiveForeground ? Color.white.opacity(0.25) : cmuxAccentColor() } private var activeProgressTrackColor: Color { @@ -6018,7 +6038,7 @@ private struct TabItemView: View { } private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.8) : Color.accentColor + usesInvertedActiveForeground ? Color.white.opacity(0.8) : cmuxAccentColor() } private var shortcutHintEmphasis: Double { @@ -6289,7 +6309,7 @@ private struct TabItemView: View { .overlay(alignment: .top) { if showsCenteredTopDropIndicator { Rectangle() - .fill(Color.accentColor) + .fill(cmuxAccentColor()) .frame(height: 2) .padding(.horizontal, 8) .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) @@ -6492,7 +6512,7 @@ private struct TabItemView: View { switch activeTabIndicatorStyle { case .leftRail: if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } - if isMultiSelected { return Color.accentColor.opacity(0.25) } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } return Color.clear case .solidFill: if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } @@ -6500,7 +6520,7 @@ private struct TabItemView: View { if isMultiSelected { return custom.opacity(0.35) } return custom.opacity(0.7) } - if isMultiSelected { return Color.accentColor.opacity(0.25) } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } return Color.clear } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ecb5b7dc..89229133 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3619,8 +3619,8 @@ final class GhosttySurfaceScrollView: NSView { inactiveOverlayView.isHidden = true addSubview(inactiveOverlayView) dropZoneOverlayView.wantsLayer = true - dropZoneOverlayView.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.25).cgColor - dropZoneOverlayView.layer?.borderColor = NSColor.controlAccentColor.cgColor + dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor + dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor dropZoneOverlayView.layer?.borderWidth = 2 dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index f04841ed..53cc8737 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -182,11 +182,11 @@ private struct NotificationRow: View { Button(action: onOpen) { HStack(alignment: .top, spacing: 12) { Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) + .fill(notification.isRead ? Color.clear : cmuxAccentColor()) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 82069f74..b3ff4844 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { // Matches Bonsplit tab icon tint for active tabs. return Color(nsColor: .labelColor) case .accent: - return .accentColor + return cmuxAccentColor() case .tertiary: return Color(nsColor: .tertiaryLabelColor) } @@ -288,8 +288,8 @@ struct BrowserPanelView: View { } .overlay { RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) - .stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3) - .shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } @@ -676,7 +676,7 @@ struct BrowserPanelView: View { ) .overlay( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) - .stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1) + .stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1) ) .accessibilityElement(children: .contain) .background { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index ff73c91a..84ac40d3 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -333,7 +333,7 @@ struct TitlebarControlsView: View { .foregroundColor(.white) .frame(width: config.badgeSize, height: config.badgeSize) .background( - Circle().fill(Color.accentColor) + Circle().fill(cmuxAccentColor()) ) .offset(x: config.badgeOffset.width, y: config.badgeOffset.height) } @@ -905,11 +905,11 @@ private struct NotificationPopoverRow: View { Button(action: onOpen) { HStack(alignment: .top, spacing: 10) { Circle() - .fill(notification.isRead ? Color.clear : Color.accentColor) + .fill(notification.isRead ? Color.clear : cmuxAccentColor()) .frame(width: 8, height: 8) .overlay( Circle() - .stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) + .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) ) .padding(.top, 6) diff --git a/Sources/Update/UpdateViewModel.swift b/Sources/Update/UpdateViewModel.swift index 8aa275af..dd8a6697 100644 --- a/Sources/Update/UpdateViewModel.swift +++ b/Sources/Update/UpdateViewModel.swift @@ -132,7 +132,7 @@ class UpdateViewModel: ObservableObject { case .checking: return .secondary case .updateAvailable: - return .accentColor + return cmuxAccentColor() case .downloading, .extracting, .installing: return .secondary case .notFound: @@ -147,7 +147,7 @@ class UpdateViewModel: ObservableObject { case .permissionRequest: return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) case .updateAvailable: - return .accentColor + return cmuxAccentColor() case .notFound: return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) case .error: diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 7f742558..132f2374 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -850,9 +850,9 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { return } - XCTAssertEqual(color.redComponent, 62.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 133.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 252.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) } @@ -862,9 +862,9 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { return } - XCTAssertEqual(color.redComponent, 63.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 142.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 252.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) + XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001) + XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) } From 9db730718bcd59a3e136ab4db91e27ca4ca0104d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:29:01 -0800 Subject: [PATCH 099/136] Fix browser eval CLI output --- CLI/cmux.swift | 29 ++++++- ...test_browser_eval_cli_output_regression.py | 87 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/test_browser_eval_cli_output_regression.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3d3daf92..191ff350 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2007,6 +2007,27 @@ struct CMUXCLI { } } + func displayBrowserValue(_ value: Any) -> String { + if value is NSNull { + return "null" + } + if let string = value as? String { + return string + } + if let bool = value as? Bool { + return bool ? "true" : "false" + } + if let number = value as? NSNumber { + return number.stringValue + } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]), + let text = String(data: data, encoding: .utf8) { + return text + } + return String(describing: value) + } + func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -2174,7 +2195,13 @@ struct CMUXCLI { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) - output(payload, fallback: "OK") + let fallback: String + if let value = payload["value"] { + fallback = displayBrowserValue(value) + } else { + fallback = "OK" + } + output(payload, fallback: fallback) return } diff --git a/tests/test_browser_eval_cli_output_regression.py b/tests/test_browser_eval_cli_output_regression.py new file mode 100644 index 00000000..b8778a52 --- /dev/null +++ b/tests/test_browser_eval_cli_output_regression.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Static regression guard for browser eval CLI output formatting. + +Ensures `cmux browser eval