From b6b949381719188f72a0df39304c01b4e903eefc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:15:01 -0700 Subject: [PATCH 1/2] Add failing coverage: Cmd+[ / Cmd+] must traverse global workspace focus history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty's macOS defaults bind goto_split:previous/next to cmd+[ / cmd+], the same keys as Focus Back/Forward. The shortcut dispatch mirrors those triggers to cycle pane focus and checks the mirror before the focus-history branch, so the keys cycle panes inside the current workspace (or do nothing) while the titlebar arrow buttons navigate across workspaces. Coverage added ahead of the fix so CI shows red then green: - cmuxTests/FocusHistoryBracketShortcutRoutingTests: dispatches real ⌘[ / ⌘] events through debugHandleCustomShortcut with the Ghostty mirror installed via a new DEBUG seam; expects workspace focus-history navigation. - cmuxUITests/FocusHistoryShortcutUITests: end-to-end over the control socket (simulate_shortcut uses the same matcher as the app-level monitor); walks back/forward across three workspaces and checks closed-workspace skipping. - tests_v2/test_focus_history_shortcut_cross_workspace.py: local socket verification against a tagged build. Co-Authored-By: Claude Fable 5 --- Sources/AppDelegate.swift | 11 + cmux.xcodeproj/project.pbxproj | 8 + ...usHistoryBracketShortcutRoutingTests.swift | 173 +++++++++++ cmuxUITests/FocusHistoryShortcutUITests.swift | 292 ++++++++++++++++++ ..._focus_history_shortcut_cross_workspace.py | 102 ++++++ 5 files changed, 586 insertions(+) create mode 100644 cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift create mode 100644 cmuxUITests/FocusHistoryShortcutUITests.swift create mode 100644 tests_v2/test_focus_history_shortcut_cross_workspace.py diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f5faddec3e95..569081958d18 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -783,6 +783,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var ghosttyGotoSplitDownShortcut: StoredShortcut? private var ghosttyGotoSplitPreviousShortcut: StoredShortcut? private var ghosttyGotoSplitNextShortcut: StoredShortcut? + +#if DEBUG + /// Test seam: unit tests can install the mirrored Ghostty + /// goto_split:previous/next triggers without loading a Ghostty config + /// (Ghostty's macOS defaults put them on ⌘[ / ⌘], colliding with the + /// focus-history defaults this dispatch must win). + func debugSetGhosttyGotoSplitCycleShortcuts(previous: StoredShortcut?, next: StoredShortcut?) { + ghosttyGotoSplitPreviousShortcut = previous + ghosttyGotoSplitNextShortcut = next + } +#endif private var browserAddressBarFocusedPanelId: UUID? /// Owns the browser omnibar selection-repeat state machine, extracted into /// `CmuxBrowser`. The app delegate is the composition root: it injects diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 770d332fdb08..4792d60897dc 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1053,8 +1053,10 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0F15A000000000000000001 /* FishShellIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0F15A000000000000000002 /* FishShellIntegrationTests.swift */; }; F17A5F010000000000000001 /* FocusedPanelFlashShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */; }; F0C05170000000000000002 /* FocusHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C05170000000000000001 /* FocusHistory.swift */; }; + FE00AA20C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */; }; C4160A020000000000000001 /* FocusHistoryMenuInvalidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A020000000000000002 /* FocusHistoryMenuInvalidator.swift */; }; C0DE86540000000000000002 /* FocusHistoryScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE86540000000000000001 /* FocusHistoryScopeTests.swift */; }; + FE00AA10C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */; }; 5E55400000000000000000D1 /* FocusStealingResponderConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E55400000000000000000D2 /* FocusStealingResponderConformances.swift */; }; C0DEFB100000000000000001 /* FocusSurfaceBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */; }; C0DEFB300000000000000001 /* FocusSurfaceBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFB300000000000000002 /* FocusSurfaceBroadcasterTests.swift */; }; @@ -3599,8 +3601,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0F15A000000000000000002 /* FishShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FishShellIntegrationTests.swift; sourceTree = ""; }; F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedPanelFlashShortcutTests.swift; sourceTree = ""; }; F0C05170000000000000001 /* FocusHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistory.swift; sourceTree = ""; }; + FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryBracketShortcutRoutingTests.swift; sourceTree = ""; }; C4160A020000000000000002 /* FocusHistoryMenuInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryMenuInvalidator.swift; sourceTree = ""; }; C0DE86540000000000000001 /* FocusHistoryScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryScopeTests.swift; sourceTree = ""; }; + FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryShortcutUITests.swift; sourceTree = ""; }; 5E55400000000000000000D2 /* FocusStealingResponderConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusStealingResponderConformances.swift; sourceTree = ""; }; C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcaster.swift; sourceTree = ""; }; C0DEFB300000000000000002 /* FocusSurfaceBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcasterTests.swift; sourceTree = ""; }; @@ -5260,6 +5264,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */, + FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */, D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, @@ -7199,6 +7204,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D77800010000000000000002 /* DockPortalReconcileTests.swift */, 8777D0028777D0028777D002 /* DockRuntimeParityTests.swift */, D81390010000000000000002 /* DockShortcutRoutingTests.swift */, + FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */, D86860000000000000000002 /* DockWorkingDirectoryInheritanceTests.swift */, D7054D0C0000000000000002 /* DockTerminalReattachTests.swift */, A9042D0C0000000000000002 /* DockTerminalPointerFocusTests.swift */, @@ -10018,6 +10024,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */, D0E0F0B4A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift in Sources */, + FE00AA10C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift in Sources */, CC000000A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift in Sources */, C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */, B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, @@ -10324,6 +10331,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C35610000000000000000001 /* FinderFileDropRegressionTests.swift in Sources */, C0F15A000000000000000001 /* FishShellIntegrationTests.swift in Sources */, F17A5F010000000000000001 /* FocusedPanelFlashShortcutTests.swift in Sources */, + FE00AA20C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift in Sources */, C0DE86540000000000000002 /* FocusHistoryScopeTests.swift in Sources */, C0DEFB300000000000000001 /* FocusSurfaceBroadcasterTests.swift in Sources */, AA5269A0C0DE0004FACE0004 /* ForeignFirstResponderPolicyTests.swift in Sources */, diff --git a/cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift b/cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift new file mode 100644 index 000000000000..72c2afb07a67 --- /dev/null +++ b/cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift @@ -0,0 +1,173 @@ +import AppKit +import CmuxSettings +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +private typealias AppStoredShortcut = cmux_DEV.StoredShortcut +#elseif canImport(cmux) +@testable import cmux +private typealias AppStoredShortcut = cmux.StoredShortcut +#endif + +/// Regression: Cmd+[ / Cmd+] must reach the focus-history branch of the +/// shortcut dispatch even though the mirrored Ghostty goto_split:previous/next +/// triggers sit on the same keys (Ghostty's macOS defaults) and are checked +/// earlier. Before the fix the mirror consumed the keys unconditionally, so the +/// shortcut cycled panes inside the current workspace while the titlebar arrow +/// buttons (same `TabManager.navigateBack()/navigateForward()` model) navigated +/// across workspaces. +@Suite("Focus history bracket shortcut routing", .serialized) +struct FocusHistoryBracketShortcutRoutingTests { + @Test("Cmd+[ / Cmd+] navigate workspace focus history despite the Ghostty goto_split mirror") + @MainActor + func bracketsNavigateWorkspaceHistoryDespiteGotoSplitMirror() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + // Mirror Ghostty's macOS defaults: goto_split:previous/next on ⌘[ / ⌘]. + harness.appDelegate.debugSetGhosttyGotoSplitCycleShortcuts( + previous: Self.commandBracketShortcut("["), + next: Self.commandBracketShortcut("]") + ) + + let firstWorkspace = harness.firstWorkspace + let secondWorkspace = harness.tabManager.addWorkspace(select: true, eagerLoadTerminal: false) + #expect(harness.tabManager.selectedTabId == secondWorkspace.id) + + #expect(Self.dispatch(Self.commandBracketShortcut("["), in: harness)) + #expect( + harness.tabManager.selectedTabId == firstWorkspace.id, + "Cmd+[ must walk focus history back across workspaces, not cycle panes" + ) + + #expect(Self.dispatch(Self.commandBracketShortcut("]"), in: harness)) + #expect( + harness.tabManager.selectedTabId == secondWorkspace.id, + "Cmd+] must walk focus history forward across workspaces" + ) + } + } + } + + @Test("Unbinding Focus Back/Forward hands the bracket keys back to the goto_split mirror") + @MainActor + func unboundFocusHistoryYieldsBracketsToGotoSplitMirror() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + harness.appDelegate.debugSetGhosttyGotoSplitCycleShortcuts( + previous: Self.commandBracketShortcut("["), + next: Self.commandBracketShortcut("]") + ) + + _ = harness.tabManager.addWorkspace(select: true, eagerLoadTerminal: false) + let selectedBefore = harness.tabManager.selectedTabId + + KeyboardShortcutSettings.clearShortcut(for: .focusHistoryBack) + KeyboardShortcutSettings.clearShortcut(for: .focusHistoryForward) + + #expect( + Self.dispatch(Self.commandBracketShortcut("["), in: harness), + "The goto_split mirror should still consume ⌘[ once focus history is unbound" + ) + #expect( + harness.tabManager.selectedTabId == selectedBefore, + "Pane cycling stays inside the current workspace" + ) + } + } + } + + // MARK: - Harness (main-area sibling of DockShortcutRoutingTests.withHarness) + + struct Harness { + let appDelegate: AppDelegate + let tabManager: TabManager + let firstWorkspace: Workspace + let window: NSWindow + } + + @MainActor + static func withHarness(_ body: (Harness) throws -> Void) throws { + let previousAppDelegate = AppDelegate.shared + let previousManager = TerminalController.shared.activeTabManagerForCallerNotification() + let originalSettingsFileStore = KeyboardShortcutSettings.installIsolatedTestFileStore( + prefix: "cmux-focus-history-bracket-routing" + ) + KeyboardShortcutSettings.resetAll() + + let appDelegate = AppDelegate() + let suiteName = "FocusHistoryBracketShortcutRoutingTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + let settings = UserDefaultsSettingsClient(defaults: defaults) + let manager = TabManager(autoWelcomeIfNeeded: false, settings: settings) + let windowId = UUID() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)") + + AppDelegate.shared = appDelegate + appDelegate.tabManager = manager + TerminalController.shared.setActiveTabManager(manager) + appDelegate.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + window.makeKeyAndOrderFront(nil) + + let firstWorkspace = try #require(manager.tabs.first) + + defer { + defaults.removePersistentDomain(forName: suiteName) + KeyboardShortcutSettings.resetAll() + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + TerminalController.shared.setActiveTabManager(previousManager) + appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) + manager.tabs.forEach { $0.teardownAllPanels() } + window.orderOut(nil) + window.close() + AppDelegate.shared = previousAppDelegate + } + + try body(Harness( + appDelegate: appDelegate, + tabManager: manager, + firstWorkspace: firstWorkspace, + window: window + )) + } + + static func commandBracketShortcut(_ key: String) -> AppStoredShortcut { + AppStoredShortcut(key: key, command: true, shift: false, option: false, control: false) + } + + @MainActor + static func dispatch(_ shortcut: AppStoredShortcut, in harness: Harness) -> Bool { + guard let keyCode = shortcut.firstStroke.resolvedKeyCode() else { return false } + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: shortcut.modifierFlags, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: harness.window.windowNumber, + context: nil, + characters: shortcut.key, + charactersIgnoringModifiers: shortcut.key, + isARepeat: false, + keyCode: keyCode + ) else { return false } +#if DEBUG + return harness.appDelegate.debugHandleCustomShortcut(event: event) +#else + return false +#endif + } +} diff --git a/cmuxUITests/FocusHistoryShortcutUITests.swift b/cmuxUITests/FocusHistoryShortcutUITests.swift new file mode 100644 index 000000000000..2dd76317a941 --- /dev/null +++ b/cmuxUITests/FocusHistoryShortcutUITests.swift @@ -0,0 +1,292 @@ +import XCTest +import Foundation +import Darwin + +/// Regression coverage for https://github.com/manaflow-ai/cmux issue: Cmd+[ / +/// Cmd+] must traverse the GLOBAL workspace focus history (the same +/// `TabManager.navigateBack()/navigateForward()` path as the titlebar arrow +/// buttons), not cycle panes inside the current workspace. +/// +/// Ghostty's macOS defaults put `goto_split:previous/next` on ⌘[ / ⌘], the same +/// keys as cmux's Focus Back/Forward defaults. The app-level dispatch mirrors +/// those Ghostty triggers to cycle pane focus and runs that mirror before the +/// focus-history branch, so before the fix the mirror consumed ⌘[ / ⌘] +/// unconditionally: pressing the keys cycled panes within the workspace (or did +/// nothing in a single-pane workspace) while the titlebar arrows navigated +/// across workspaces. +/// +/// The tests drive the shortcut through `simulate_shortcut`, which routes +/// through `AppDelegate.debugHandleCustomShortcut` — the exact same matcher and +/// dispatch order as real keystrokes from the app-level event monitor — so the +/// old bug reproduces deterministically on headless CI runners where real +/// keystroke foregrounding is flaky. +final class FocusHistoryShortcutUITests: XCTestCase { + private var socketPath = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + socketPath = "/tmp/cmux-ui-test-focus-history-\(UUID().uuidString).sock" + try? FileManager.default.removeItem(atPath: socketPath) + } + + override func tearDown() { + try? FileManager.default.removeItem(atPath: socketPath) + super.tearDown() + } + + func testCmdBracketWalksWorkspaceFocusHistoryBackAndForward() { + let (_, cleanup) = launchIsolatedApp() + defer { cleanup() } + + guard let workspaces = createAndVisitWorkspaces(count: 3) else { return } + + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[2], timeout: 10.0), + "Expected focus on the last created workspace before navigating" + ) + + // Back: ws3 -> ws2 -> ws1. Before the fix the Ghostty goto_split mirror + // consumed ⌘[ and the current workspace never changed. + simulateShortcut("cmd+[") + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[1], timeout: 10.0), + "Cmd+[ should navigate focus history back across workspaces (ws3 -> ws2). current=\(currentWorkspace() ?? "nil")" + ) + simulateShortcut("cmd+[") + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[0], timeout: 10.0), + "Second Cmd+[ should continue back across workspaces (ws2 -> ws1). current=\(currentWorkspace() ?? "nil")" + ) + + // Forward: ws1 -> ws2 -> ws3, proving the forward stack survives. + simulateShortcut("cmd+]") + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[1], timeout: 10.0), + "Cmd+] should navigate focus history forward (ws1 -> ws2). current=\(currentWorkspace() ?? "nil")" + ) + simulateShortcut("cmd+]") + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[2], timeout: 10.0), + "Second Cmd+] should continue forward (ws2 -> ws3). current=\(currentWorkspace() ?? "nil")" + ) + } + + func testCmdBracketSkipsClosedWorkspacesLikeTheArrowButtons() { + let (_, cleanup) = launchIsolatedApp() + defer { cleanup() } + + guard let workspaces = createAndVisitWorkspaces(count: 3) else { return } + + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[2], timeout: 10.0), + "Expected focus on the last created workspace before closing ws2" + ) + + let closeReply = socketCommand("close_workspace \(workspaces[1])") + XCTAssertEqual(closeReply, "OK", "Expected ws2 to close non-interactively, got \(closeReply ?? "nil")") + + // Back from ws3 must skip the closed ws2 and land on ws1, matching the + // titlebar arrow buttons' closed-workspace pruning. + simulateShortcut("cmd+[") + XCTAssertTrue( + waitForCurrentWorkspace(workspaces[0], timeout: 10.0), + "Cmd+[ should skip the closed workspace and land on ws1. current=\(currentWorkspace() ?? "nil")" + ) + } + + // MARK: - Workspace setup over the control socket + + /// Creates `count` workspaces and visits each in creation order so the + /// focus-history stack ends as [start, ws1, ..., wsN] with wsN current. + private func createAndVisitWorkspaces(count: Int) -> [String]? { + var created: [String] = [] + for index in 1...count { + let reply = socketCommand("new_workspace focus-history-ws\(index)") + guard let reply, + reply.hasPrefix("OK "), + let id = reply.split(separator: " ").last.map(String.init), + UUID(uuidString: id) != nil else { + XCTFail("new_workspace #\(index) failed: \(reply ?? "nil")") + return nil + } + created.append(id) + } + for id in created { + guard socketCommand("select_workspace \(id)") == "OK" else { + XCTFail("select_workspace \(id) failed") + return nil + } + // Selection records focus history synchronously in the + // selectedTabId didSet; a short settle keeps ordering deterministic. + RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + } + return created + } + + private func simulateShortcut(_ combo: String) { + let reply = socketCommand("simulate_shortcut \(combo)", responseTimeout: 30.0) + XCTAssertEqual(reply, "OK", "simulate_shortcut \(combo) failed: \(reply ?? "nil")") + } + + private func currentWorkspace() -> String? { + guard let reply = socketCommand("current_workspace"), + UUID(uuidString: reply) != nil else { return nil } + return reply + } + + private func waitForCurrentWorkspace(_ workspaceId: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if currentWorkspace() == workspaceId { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return currentWorkspace() == workspaceId + } + + // MARK: - Launch + + private func launchIsolatedApp() -> (XCUIApplication, () -> Void) { + let fileManager = FileManager.default + let isolatedHome = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-ui-test-focus-history-\(UUID().uuidString)", + isDirectory: true + ) + try? fileManager.createDirectory(at: isolatedHome, withIntermediateDirectories: true) + + let app = XCUIApplication.cmuxTestApplication() + // Isolated HOME: no user cmux.json shortcut overrides, no user Ghostty + // keybinds, no restored session — the test exercises factory defaults. + app.launchEnvironment["HOME"] = isolatedHome.path + app.launchEnvironment["CFFIXED_USER_HOME"] = isolatedHome.path + app.launchEnvironment["XDG_CONFIG_HOME"] = + isolatedHome.appendingPathComponent(".config", isDirectory: true).path + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_TAG"] = "ui-tests-focus-history-\(UUID().uuidString.prefix(8))" + // Backgrounded apps on CI runners get App Nap throttled and this test + // drives everything through the control socket, which replies after + // main-thread hops. + app.launchArguments += ["-NSAppSleepDisabled", "YES"] + + // The whole flow runs over the control socket, so a backgrounded app is + // fine; launch() itself raises when activation cannot win on headless + // CI runners (state stays Running Background), so tolerate that. + let launchOptions = XCTExpectedFailure.Options() + launchOptions.isStrict = false + XCTExpectFailure("App activation may fail on headless CI runners", options: launchOptions) { + app.launch() + } + + let launched = XCTWaiter().wait( + for: [XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.state == .runningForeground || app.state == .runningBackground + }, + object: NSObject() + )], + timeout: 15.0 + ) == .completed + XCTAssertTrue(launched, "App failed to start. state=\(app.state.rawValue)") + + let socketReady = waitForControlSocketReady( + pingTimeout: 20.0, + socketFileExists: { FileManager.default.fileExists(atPath: self.socketPath) }, + pingReturnsPong: { self.socketCommand("ping") == "PONG" } + ) + XCTAssertTrue(socketReady, "Control socket never answered ping at \(socketPath)") + + // App-side activation (socket main-hop) so synthetic shortcut events + // land in a prepared key window even when XCUI activation lost above. + // Also proves a main-thread hop completes before the interesting + // commands run (first launch on a clean runner can wedge for a while). + var mainHopReady = false + for _ in 0..<12 where !mainHopReady { + mainHopReady = socketCommand("activate_app", responseTimeout: 10.0) == "OK" + } + XCTAssertTrue(mainHopReady, "Main thread never serviced an activate_app socket hop") + + return (app, { + app.terminate() + try? fileManager.removeItem(at: isolatedHome) + }) + } + + // MARK: - Control socket plumbing + + private func socketCommand(_ command: String, responseTimeout: TimeInterval = 10.0) -> String? { + ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(command) + } + + private final class ControlSocketClient { + private let path: String + private let responseTimeout: TimeInterval + + init(path: String, responseTimeout: TimeInterval) { + self.path = path + self.responseTimeout = responseTimeout + } + + func sendLine(_ line: String) -> String? { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var timeout = timeval( + tv_sec: Int(responseTimeout), + tv_usec: Int32((responseTimeout - floor(responseTimeout)) * 1_000_000) + ) + withUnsafePointer(to: &timeout) { ptr in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ptr, socklen_t(MemoryLayout.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout.size)) + } + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.size) + addr.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = Array(path.utf8CString) + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + guard pathBytes.count <= maxLen else { return nil } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + for index in 0...offset(of: \.sun_path) ?? 0 + let addrLen = socklen_t(pathOffset + pathBytes.count) + let connected = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connected == 0 else { return nil } + + let payload = Array((line + "\n").utf8) + let wrote = payload.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return true } + return Darwin.write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wrote else { return nil } + + var buffer = [UInt8](repeating: 0, count: 4096) + var accumulator = "" + let deadline = Date().addingTimeInterval(responseTimeout) + while Date() < deadline { + let count = Darwin.read(fd, &buffer, buffer.count) + guard count > 0 else { break } + if let chunk = String(bytes: buffer[0...sock python3 tests_v2/test_focus_history_shortcut_cross_workspace.py + +simulate_shortcut routes through AppDelegate.debugHandleCustomShortcut, the +same matcher and dispatch order as real keystrokes from the app-level monitor. +""" + +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _selected_workspace(c: cmux) -> str: + for _idx, wsid, _title, selected in c.list_workspaces(): + if selected: + return wsid + raise cmuxError("no selected workspace") + + +def _press_and_wait(c: cmux, combo: str, expected_ws: str, timeout: float = 5.0) -> str: + c.simulate_shortcut(combo) + deadline = time.time() + timeout + current = _selected_workspace(c) + while time.time() < deadline: + current = _selected_workspace(c) + if current == expected_ws: + return current + time.sleep(0.1) + return current + + +def main() -> int: + c = cmux() + c.connect() + _must(c.ping(), "socket ping failed") + c.activate_app() + time.sleep(0.3) + + created = [] + for index in range(3): + wsid = c.new_workspace() + c.select_workspace(wsid) + c.rename_workspace(f"fhist-ws{index + 1}", wsid) + created.append(wsid) + time.sleep(0.15) + + # Visit ws1 -> ws2 -> ws3 so the focus-history stack is deterministic. + for wsid in created: + c.select_workspace(wsid) + time.sleep(0.15) + _must(_selected_workspace(c) == created[2], "expected focus on ws3 before navigating") + + # Back across workspaces: ws3 -> ws2 -> ws1. + got = _press_and_wait(c, "cmd+[", created[1]) + _must(got == created[1], f"cmd+[ should land on ws2, got {got}") + got = _press_and_wait(c, "cmd+[", created[0]) + _must(got == created[0], f"second cmd+[ should land on ws1, got {got}") + + # Forward again: ws1 -> ws2 -> ws3. + got = _press_and_wait(c, "cmd+]", created[1]) + _must(got == created[1], f"cmd+] should land on ws2, got {got}") + got = _press_and_wait(c, "cmd+]", created[2]) + _must(got == created[2], f"second cmd+] should land on ws3, got {got}") + + # Closed workspaces are skipped, matching the arrow buttons' pruning. + c.close_workspace(created[1]) + time.sleep(0.3) + got = _press_and_wait(c, "cmd+[", created[0]) + _must(got == created[0], f"cmd+[ should skip closed ws2 and land on ws1, got {got}") + + # Cleanup the workspaces this test created. + for wsid in (created[0], created[2]): + try: + c.close_workspace(wsid) + except cmuxError: + pass + + print("PASS: cmd+[ / cmd+] traverse global workspace focus history") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From e73937d2f72d46284b3361451587c06da18331cf Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:18:33 -0700 Subject: [PATCH 2/2] Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ghostty goto_split:previous/next mirror in the shortcut dispatch now yields to a bound Focus Back/Forward shortcut (matchConfiguredShortcut, including shortcuts.when gating), so ⌘[ / ⌘] reach the focus-history branch and drive the exact same TabManager.navigateBack()/navigateForward() path as the titlebar arrow buttons: same history model, same closed-workspace pruning, same enable conditions. Unbinding Focus Back/Forward hands the keys back to the mirror, as the keyboard-shortcuts docs already promised. Pane cycling stays available two ways: the Ghostty goto_split trigger on any non-colliding key, and new cmux-owned rebindable actions focusPreviousPane / focusNextPane (default unbound) that share the same cyclePaneFocus body, per the shared-entrypoint policy. The window key-equivalent fallback route gets the same yield so both dispatch layers agree. The new actions follow the full shortcut policy: KeyboardShortcutSettings + CmuxSettings ShortcutAction (defaults, display names, panes group), Settings recorder rows, cmux.json shortcuts.bindings support, schema enum, web keyboard-shortcuts page (en+ja), and the shortcut-actions reference. Labels localized in Localizable.xcstrings for all catalog languages. Co-Authored-By: Claude Fable 5 --- .../Values/ShortcutAction+Defaults.swift | 2 + .../Values/ShortcutAction+DisplayName.swift | 4 + .../Values/ShortcutAction+Group.swift | 3 +- .../CmuxSettings/Values/ShortcutAction.swift | 2 + Resources/Localizable.xcstrings | 238 ++++++++++++++++++ Sources/AppDelegate.swift | 30 ++- Sources/KeyboardShortcutSettings.swift | 12 + .../references/shortcut-actions.md | 2 + web/data/cmux-shortcuts.ts | 18 ++ web/data/cmux.schema.json | 2 + 10 files changed, 310 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index dc4fdc850469..2e574a60877a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -89,6 +89,8 @@ extension ShortcutAction { case .focusRight: return ShortcutStroke(key: "→", command: true, option: true) case .focusUp: return ShortcutStroke(key: "↑", command: true, option: true) case .focusDown: return ShortcutStroke(key: "↓", command: true, option: true) + case .focusPreviousPane: return nil + case .focusNextPane: return nil case .splitRight: return ShortcutStroke(key: "d", command: true) case .splitDown: return ShortcutStroke(key: "d", command: true, shift: true) case .toggleSplitZoom: return ShortcutStroke(key: "\r", command: true, shift: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index cfcb42cdf7dc..22be5a15dc81 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -93,6 +93,10 @@ extension ShortcutAction { case .focusRight: return "Focus Pane Right" case .focusUp: return "Focus Pane Up" case .focusDown: return "Focus Pane Down" + case .focusPreviousPane: + return String(localized: "shortcut.focusPreviousPane.label", defaultValue: "Focus Previous Pane") + case .focusNextPane: + return String(localized: "shortcut.focusNextPane.label", defaultValue: "Focus Next Pane") case .splitRight: return "Split Right" case .splitDown: return "Split Down" case .toggleSplitZoom: return "Toggle Pane Zoom" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index d53df1d92546..373912067fe9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -28,7 +28,8 @@ extension ShortcutAction { .cycleTextBoxSubmitAction, .attachTextBoxFile, .sendCtrlFToTerminal, .clearScreenKeepScrollback: return .navigation - case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown, + case .focusLeft, .focusRight, .focusUp, .focusDown, + .focusPreviousPane, .focusNextPane, .splitRight, .splitDown, .toggleSplitZoom, .increaseWorkspaceTerminalFontSize, .decreaseWorkspaceTerminalFontSize, .resetWorkspaceTerminalFontSize, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 594df5f88009..fd22d62a6765 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -106,6 +106,8 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case focusRight case focusUp case focusDown + case focusPreviousPane + case focusNextPane case splitRight case splitDown case toggleSplitZoom diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 344e18af7c7d..798a71f5e6bc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -202977,6 +202977,125 @@ } } }, + "shortcut.focusNextPane.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على اللوحة التالية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj sljedeći panel" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser næste panel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächsten Bereich fokussieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Next Pane" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar panel siguiente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le panneau suivant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta focus pannello successivo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のペインにフォーカス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 패널로 포커스" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser neste panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Fokus na następny panel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar Próximo Painel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фокус на следующую панель" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสบานหน้าต่างถัดไป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sonraki Bölmeye Odaklan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Фокус на наступну панель" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦下一个面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦下一個面板" + } + } + } + }, "shortcut.focusPaneDown.label": { "extractionState": "manual", "localizations": { @@ -203453,6 +203572,125 @@ } } }, + "shortcut.focusPreviousPane.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التركيز على اللوحة السابقة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Fokusiraj prethodni panel" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fokuser forrige panel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorherigen Bereich fokussieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Previous Pane" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enfocar panel anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le panneau précédent" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sposta focus pannello precedente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前のペインにフォーカス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이전 패널로 포커스" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fokuser forrige panel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Fokus na poprzedni panel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Focar Painel Anterior" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фокус на предыдущую панель" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โฟกัสบานหน้าต่างก่อนหน้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Önceki Bölmeye Odaklan" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Фокус на попередню панель" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聚焦上一个面板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "聚焦上一個面板" + } + } + } + }, "shortcut.focusRightSidebar.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 569081958d18..76dcb336284a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14047,7 +14047,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchesGhosttyGotoSplitPreviousShortcut(event) { + // Pane focus cycling. `focusPreviousPane` / `focusNextPane` are the + // cmux-owned rebindable entries (default unbound); Ghostty's + // goto_split:previous/next triggers (⌘[ / ⌘] in Ghostty's macOS + // defaults) stay honored as a fallback so terminal-config bindings + // keep working. The Ghostty mirror must yield to a bound Focus + // Back/Forward shortcut: both default to ⌘[ / ⌘], and without the + // yield the mirror consumed the key here, cycling panes within the + // workspace while the focus-history branch below became unreachable + // (the titlebar arrows navigated globally; the shortcut never did). + if matchConfiguredShortcut(event: event, action: .focusPreviousPane) || + (matchesGhosttyGotoSplitPreviousShortcut(event) && + !ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(event)) { let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) let moved = routedTabs?.cyclePaneFocus(forward: false) ?? false @@ -14063,7 +14074,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchesGhosttyGotoSplitNextShortcut(event) { + if matchConfiguredShortcut(event: event, action: .focusNextPane) || + (matchesGhosttyGotoSplitNextShortcut(event) && + !ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(event)) { let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) let moved = routedTabs?.cyclePaneFocus(forward: true) ?? false @@ -15826,10 +15839,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent fileprivate func shouldRouteGhosttyGotoSplitCycleShortcutToTerminal(_ event: NSEvent) -> Bool { guard event.type == .keyDown else { return false } + guard !ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(event) else { return false } return matchesGhosttyGotoSplitPreviousShortcut(event) || matchesGhosttyGotoSplitNextShortcut(event) } + /// Whether an event that matches Ghostty's mirrored goto_split:previous/next + /// trigger belongs to a bound Focus Back/Forward shortcut instead. Ghostty's + /// macOS defaults put goto_split:previous/next on ⌘[ / ⌘], the same keys as + /// cmux's focus-history defaults, and the mirror branches run earlier in the + /// dispatch, so without this yield the configured focus-history shortcut is + /// unreachable. Unbinding Focus Back/Forward hands the keys back to the + /// pane-cycle mirror. + func ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(_ event: NSEvent) -> Bool { + matchConfiguredShortcut(event: event, action: .focusHistoryBack) + || matchConfiguredShortcut(event: event, action: .focusHistoryForward) + } + private func matchesGhosttyGotoSplitPreviousShortcut(_ event: NSEvent) -> Bool { guard let ghosttyGotoSplitPreviousShortcut else { return false } return matchShortcut(event: event, shortcut: ghosttyGotoSplitPreviousShortcut) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f77dc0747ec3..c93feb9eefa1 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -135,6 +135,8 @@ enum KeyboardShortcutSettings { case focusRight case focusUp case focusDown + case focusPreviousPane + case focusNextPane case splitRight case splitDown, toggleSplitZoom case increaseWorkspaceTerminalFontSize @@ -281,6 +283,8 @@ enum KeyboardShortcutSettings { case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right") case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up") case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down") + case .focusPreviousPane: return String(localized: "shortcut.focusPreviousPane.label", defaultValue: "Focus Previous Pane") + case .focusNextPane: return String(localized: "shortcut.focusNextPane.label", defaultValue: "Focus Next Pane") case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right") case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down") case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom") @@ -485,6 +489,14 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "↑", command: true, shift: false, option: true, control: false) case .focusDown: return StoredShortcut(key: "↓", command: true, shift: false, option: true, control: false) + // Unbound by default: Ghostty's goto_split:previous/next mirror still + // cycles panes on the terminal-config keys when Focus Back/Forward do + // not claim them; these entries exist so pane cycling stays rebindable + // now that ⌘[ / ⌘] reach global focus history. + case .focusPreviousPane: + return .unbound + case .focusNextPane: + return .unbound case .splitRight: return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md index f552cdc4f6d4..c05ee8e2d3ba 100644 --- a/skills/cmux-settings/references/shortcut-actions.md +++ b/skills/cmux-settings/references/shortcut-actions.md @@ -70,6 +70,8 @@ Values for `shortcuts.bindings.`: - `shortcuts.bindings.focusRight` - `shortcuts.bindings.focusUp` - `shortcuts.bindings.focusDown` +- `shortcuts.bindings.focusPreviousPane` +- `shortcuts.bindings.focusNextPane` - `shortcuts.bindings.splitRight` - `shortcuts.bindings.splitDown` - `shortcuts.bindings.toggleSplitZoom` diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 6e2f57cd79c5..0a961486b00d 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -312,6 +312,24 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } }, { id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } }, { id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } }, + { + id: "focusPreviousPane", + combos: [], + description: { en: "Focus previous pane (cycle)", ja: "前のペインにフォーカス(循環)" }, + note: { + en: "unbound by default; a Ghostty goto_split:previous keybind also cycles panes while Focus Back does not claim the same keys", + ja: "デフォルトでは未割り当て。Ghostty の goto_split:previous のキーバインドでも循環できます(Focus Back が同じキーを使っていない場合)", + }, + }, + { + id: "focusNextPane", + combos: [], + description: { en: "Focus next pane (cycle)", ja: "次のペインにフォーカス(循環)" }, + note: { + en: "unbound by default; a Ghostty goto_split:next keybind also cycles panes while Focus Forward does not claim the same keys", + ja: "デフォルトでは未割り当て。Ghostty の goto_split:next のキーバインドでも循環できます(Focus Forward が同じキーを使っていない場合)", + }, + }, { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index e6a7a4284345..4148c4f7b0d7 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1661,6 +1661,8 @@ "focusRight", "focusUp", "focusDown", + "focusPreviousPane", + "focusNextPane", "splitRight", "splitDown", "toggleSplitZoom",