Merge pull request #9329 from manaflow-ai/feat-cmd-bracket-workspace-history-2

Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable
This commit is contained in:
Abdulaziz Albahar
2026-08-01 04:50:28 -05:00
committed by GitHub
14 changed files with 881 additions and 3 deletions
@@ -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)
@@ -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"
@@ -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,
@@ -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
+238
View File
@@ -204427,6 +204427,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": {
@@ -204903,6 +205022,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": {
+10 -2
View File
@@ -14199,7 +14199,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchesGhosttyGotoSplitFallback(event: event, route: .previous) {
// Pane focus cycling. `focusPreviousPane` / `focusNextPane` are the
// cmux-owned rebindable entries (default unbound). Ghostty's imported
// goto_split:previous/next triggers ([ / ] in Ghostty's macOS
// defaults) remain compatibility fallbacks that yield to any live
// configured shortcut (matchesGhosttyGotoSplitFallback), so a bound
// Focus Back/Forward keeps [ / ] on global focus history.
if matchConfiguredShortcut(event: event, action: .focusPreviousPane) ||
matchesGhosttyGotoSplitFallback(event: event, route: .previous) {
let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager
cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow)
let moved = routedTabs?.cyclePaneFocus(forward: false) ?? false
@@ -14215,7 +14222,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchesGhosttyGotoSplitFallback(event: event, route: .next) {
if matchConfiguredShortcut(event: event, action: .focusNextPane) ||
matchesGhosttyGotoSplitFallback(event: event, route: .next) {
let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager
cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow)
let moved = routedTabs?.cyclePaneFocus(forward: true) ?? false
+12
View File
@@ -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)
+8
View File
@@ -1096,8 +1096,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 */; };
@@ -3682,8 +3684,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C0F15A000000000000000002 /* FishShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FishShellIntegrationTests.swift; sourceTree = "<group>"; };
F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedPanelFlashShortcutTests.swift; sourceTree = "<group>"; };
F0C05170000000000000001 /* FocusHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistory.swift; sourceTree = "<group>"; };
FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryBracketShortcutRoutingTests.swift; sourceTree = "<group>"; };
C4160A020000000000000002 /* FocusHistoryMenuInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryMenuInvalidator.swift; sourceTree = "<group>"; };
C0DE86540000000000000001 /* FocusHistoryScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryScopeTests.swift; sourceTree = "<group>"; };
FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryShortcutUITests.swift; sourceTree = "<group>"; };
5E55400000000000000000D2 /* FocusStealingResponderConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusStealingResponderConformances.swift; sourceTree = "<group>"; };
C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcaster.swift; sourceTree = "<group>"; };
C0DEFB300000000000000002 /* FocusSurfaceBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcasterTests.swift; sourceTree = "<group>"; };
@@ -5372,6 +5376,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 */,
@@ -7327,6 +7332,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 */,
@@ -10191,6 +10197,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 */,
@@ -10498,6 +10505,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 */,
@@ -0,0 +1,169 @@
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.ghosttyGotoSplitPreviousShortcut = Self.commandBracketShortcut("[")
harness.appDelegate.ghosttyGotoSplitNextShortcut = 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.ghosttyGotoSplitPreviousShortcut = Self.commandBracketShortcut("[")
harness.appDelegate.ghosttyGotoSplitNextShortcut = 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
))
}
fileprivate static func commandBracketShortcut(_ key: String) -> AppStoredShortcut {
AppStoredShortcut(key: key, command: true, shift: false, option: false, control: false)
}
@MainActor
fileprivate 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
}
}
@@ -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<timeval>.size))
_ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout<timeval>.size))
}
var addr = sockaddr_un()
memset(&addr, 0, MemoryLayout<sockaddr_un>.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..<pathBytes.count {
raw[index] = pathBytes[index]
}
}
let pathOffset = MemoryLayout<sockaddr_un>.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..<count], encoding: .utf8) {
accumulator.append(chunk)
if let newline = accumulator.firstIndex(of: "\n") {
return String(accumulator[..<newline])
}
if count < buffer.count {
break
}
}
}
return accumulator.isEmpty ? nil : accumulator.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}
@@ -70,6 +70,8 @@ Values for `shortcuts.bindings.<action>`:
- `shortcuts.bindings.focusRight`
- `shortcuts.bindings.focusUp`
- `shortcuts.bindings.focusDown`
- `shortcuts.bindings.focusPreviousPane`
- `shortcuts.bindings.focusNextPane`
- `shortcuts.bindings.splitRight`
- `shortcuts.bindings.splitDown`
- `shortcuts.bindings.toggleSplitZoom`
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Regression: Cmd+[ / Cmd+] traverse the GLOBAL workspace focus history.
Ghostty's macOS defaults bind goto_split:previous/next to cmd+[ / cmd+], the
same keys as cmux's Focus Back/Forward defaults. The app-level dispatch mirrors
those Ghostty triggers to cycle pane focus and used to run that mirror BEFORE
the focus-history branch, so cmd+[ / cmd+] cycled panes within the current
workspace (or did nothing) while the titlebar arrow buttons navigated across
workspaces. The mirror must yield the keys to a bound Focus Back/Forward
shortcut.
Run against a TAGGED build only (never the user's default socket):
CMUX_SOCKET_PATH=/tmp/cmux-debug-<tag>.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 _wait_selected(c: cmux, wsid: str, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if _selected_workspace(c) == wsid:
return
time.sleep(0.1)
raise cmuxError(f"workspace {wsid} never became selected")
def _wait_closed(c: cmux, wsid: str, timeout: float = 5.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if all(listed != wsid for _i, listed, _t, _s in c.list_workspaces()):
return
time.sleep(0.1)
raise cmuxError(f"workspace {wsid} still listed after close")
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)
_wait_selected(c, wsid)
# Visit ws1 -> ws2 -> ws3 so the focus-history stack is deterministic.
for wsid in created:
c.select_workspace(wsid)
_wait_selected(c, wsid)
_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])
_wait_closed(c, created[1])
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())
+18
View File
@@ -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: "右にブラウザ分割" } },
+2
View File
@@ -1661,6 +1661,8 @@
"focusRight",
"focusUp",
"focusDown",
"focusPreviousPane",
"focusNextPane",
"splitRight",
"splitDown",
"toggleSplitZoom",