From 4d03b92a88ba197a438879b83d031aa4715f08db Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:08:52 -0700 Subject: [PATCH 1/8] test: cover Ghostty tab keybindings --- .../AppDelegateShortcutRoutingTests.swift | 211 ++++++++++++++++++ cmuxTests/KeyboardShortcutSpaceKeyTests.swift | 40 ++++ cmuxTests/WorkspaceUnitTests.swift | 25 +++ 3 files changed, 276 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index e708ff7f4561..f445c867b903 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -101,6 +101,61 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return ghostty_input_mods_e(rawValue: rawValue) } + private func ghosttyConfigTrigger( + _ config: ghostty_config_t, + action: String + ) -> ghostty_input_trigger_s { + action.withCString { pointer in + ghostty_config_trigger(config, pointer, UInt(action.lengthOfBytes(using: .utf8))) + } + } + + private func withTemporaryGhosttyConfig( + _ contents: String, + body: () -> Void + ) throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-tab-keybind-\(UUID().uuidString)", isDirectory: true) + let configDirectory = root.appendingPathComponent(".config/ghostty", isDirectory: true) + try fileManager.createDirectory(at: configDirectory, withIntermediateDirectories: true) + try contents.write( + to: configDirectory.appendingPathComponent("config", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let environmentKeys = ["HOME", "CFFIXED_USER_HOME", "XDG_CONFIG_HOME"] + let originalEnvironment = environmentKeys.map { key in + (key, getenv(key).map { String(cString: $0) }) + } + defer { + for (key, value) in originalEnvironment { + if let value { + setenv(key, value, 1) + } else { + unsetenv(key) + } + } + GhosttyApp.shared.reloadConfiguration( + source: "test.restoreGhosttyConfig", + reloadSettingsFromFile: false + ) + try? fileManager.removeItem(at: root) + } + + setenv("HOME", root.path, 1) + setenv("CFFIXED_USER_HOME", root.path, 1) + setenv("XDG_CONFIG_HOME", root.appendingPathComponent(".config", isDirectory: true).path, 1) + GhosttyApp.shared.reloadConfiguration( + source: "test.temporaryGhosttyConfig", + reloadSettingsFromFile: false + ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + body() + } + override func setUp() { super.setUp() // Prevent a single hanging test from consuming the entire CI timeout budget. @@ -1374,6 +1429,162 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") } + func testGhosttyPreviousNextTabKeybindsRouteWorkspaceSelection() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let firstWorkspaceId = manager.tabs.first?.id else { + XCTFail("Expected test window and tab manager") + return + } + + let secondWorkspace = manager.addTab(select: true) + manager.selectTab(at: 0) + XCTAssertEqual(manager.selectedTabId, firstWorkspaceId) + + try withTemporaryGhosttyConfig( + """ + keybind = super+alt+left=previous_tab + keybind = super+alt+right=next_tab + """ + ) { + guard let config = GhosttyApp.shared.config else { + XCTFail("Expected loaded Ghostty config") + return + } + let nextTrigger = ghosttyConfigTrigger(config, action: "next_tab") + XCTAssertEqual(nextTrigger.tag, GHOSTTY_TRIGGER_PHYSICAL) + XCTAssertEqual(nextTrigger.key.physical, GHOSTTY_KEY_ARROW_RIGHT) + + guard let nextEvent = makeKeyDownEvent( + key: "→", + modifiers: [.command, .option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Option+Right event") + return + } + + #if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: nextEvent)) + #else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + #endif + XCTAssertEqual( + manager.selectedTabId, + secondWorkspace.id, + "Ghostty next_tab keybind should select the next cmux workspace" + ) + + guard let previousEvent = makeKeyDownEvent( + key: "←", + modifiers: [.command, .option], + keyCode: 123, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Option+Left event") + return + } + + #if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: previousEvent)) + #else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + #endif + XCTAssertEqual( + manager.selectedTabId, + firstWorkspaceId, + "Ghostty previous_tab keybind should select the previous cmux workspace" + ) + } + } + + func testGhosttyControlPageTabKeybindsRouteWorkspaceSelection() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let firstWorkspaceId = manager.tabs.first?.id else { + XCTFail("Expected test window and tab manager") + return + } + + let secondWorkspace = manager.addTab(select: true) + manager.selectTab(at: 0) + XCTAssertEqual(manager.selectedTabId, firstWorkspaceId) + + try withTemporaryGhosttyConfig( + """ + keybind = ctrl+page_up=previous_tab + keybind = ctrl+page_down=next_tab + """ + ) { + guard let config = GhosttyApp.shared.config else { + XCTFail("Expected loaded Ghostty config") + return + } + let nextTrigger = ghosttyConfigTrigger(config, action: "next_tab") + XCTAssertEqual(nextTrigger.tag, GHOSTTY_TRIGGER_PHYSICAL) + XCTAssertEqual(nextTrigger.key.physical, GHOSTTY_KEY_PAGE_DOWN) + + guard let nextEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSPageDownFunctionKey)!), + modifiers: [.control, .function], + keyCode: 121, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+PageDown event") + return + } + + #if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: nextEvent)) + #else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + #endif + XCTAssertEqual( + manager.selectedTabId, + secondWorkspace.id, + "Ghostty next_tab Page Down keybind should select the next cmux workspace" + ) + + guard let previousEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSPageUpFunctionKey)!), + modifiers: [.control, .function], + keyCode: 116, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+PageUp event") + return + } + + #if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: previousEvent)) + #else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + #endif + XCTAssertEqual( + manager.selectedTabId, + firstWorkspaceId, + "Ghostty previous_tab Page Up keybind should select the previous cmux workspace" + ) + } + } + func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift index 31d6a9eb594e..912ffe68421f 100644 --- a/cmuxTests/KeyboardShortcutSpaceKeyTests.swift +++ b/cmuxTests/KeyboardShortcutSpaceKeyTests.swift @@ -47,6 +47,46 @@ final class KeyboardShortcutSpaceKeyTests: XCTestCase { XCTAssertEqual(StoredShortcut.parseConfig(" ")?.configIdentifier, "space") } + func testShortcutConfigParsingRoundTripsPageKeys() throws { + let pageUpKeyCode = UInt16(116) + let pageDownKeyCode = UInt16(121) + + let pageUpShortcut = try XCTUnwrap(StoredShortcut.parseConfig("ctrl+page_up")) + XCTAssertEqual(pageUpShortcut.key, "pageUp") + XCTAssertFalse(pageUpShortcut.command) + XCTAssertFalse(pageUpShortcut.shift) + XCTAssertFalse(pageUpShortcut.option) + XCTAssertTrue(pageUpShortcut.control) + XCTAssertEqual(pageUpShortcut.firstStroke.resolvedKeyCode(), pageUpKeyCode) + XCTAssertEqual(pageUpShortcut.configIdentifier, "ctrl+pageUp") + XCTAssertTrue( + pageUpShortcut.matches( + keyCode: pageUpKeyCode, + modifierFlags: [.control, .function], + eventCharacter: String(UnicodeScalar(NSPageUpFunctionKey)!) + ) + ) + + let pageDownShortcut = try XCTUnwrap(StoredShortcut.parseConfig("ctrl+page_down")) + XCTAssertEqual(pageDownShortcut.key, "pageDown") + XCTAssertEqual(pageDownShortcut.firstStroke.resolvedKeyCode(), pageDownKeyCode) + XCTAssertEqual(pageDownShortcut.configIdentifier, "ctrl+pageDown") + XCTAssertTrue( + pageDownShortcut.matches( + keyCode: pageDownKeyCode, + modifierFlags: [.control, .function], + eventCharacter: String(UnicodeScalar(NSPageDownFunctionKey)!) + ) + ) + + XCTAssertEqual(StoredShortcut.parseConfig("ctrl+pageup")?.configIdentifier, "ctrl+pageUp") + XCTAssertEqual(StoredShortcut.parseConfig("ctrl+page-up")?.configIdentifier, "ctrl+pageUp") + XCTAssertEqual(StoredShortcut.parseConfig("ctrl+")?.configIdentifier, "ctrl+pageUp") + XCTAssertEqual(StoredShortcut.parseConfig("ctrl+pagedown")?.configIdentifier, "ctrl+pageDown") + XCTAssertEqual(StoredShortcut.parseConfig("ctrl+page-down")?.configIdentifier, "ctrl+pageDown") + XCTAssertEqual(StoredShortcut.parseConfig("ctrl+")?.configIdentifier, "ctrl+pageDown") + } + func testSettingsFileStoreParsesSpaceShortcutBinding() throws { let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 10f502246683..1d03aad3bbce 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1838,6 +1838,31 @@ final class StoredShortcutMatchingTests: XCTestCase { ) } + func testShortcutRecordingResultAcceptsPageKeys() { + let pageUpCharacters = String(UnicodeScalar(NSPageUpFunctionKey)!) + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.control, .function], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: pageUpCharacters, + charactersIgnoringModifiers: pageUpCharacters, + isARepeat: false, + keyCode: 116 + ) else { + XCTFail("Failed to construct Page Up event") + return + } + + XCTAssertEqual( + ShortcutStroke.recordingResult(from: event, requireModifier: true), + .accepted(ShortcutStroke(key: "pageUp", command: false, shift: false, option: false, control: true, keyCode: 116)) + ) + } + func testShortcutRecordingResultSafelyIgnoresNonMediaSystemDefinedEvent() { guard let event = NSEvent.otherEvent( with: .systemDefined, From 16c1f152a1ab4ce7100bca872514e54e013c770a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 12:03:56 -0700 Subject: [PATCH 2/8] fix: respect Ghostty tab keybindings --- Resources/Localizable.xcstrings | 34 ++++++++++++ Sources/AppDelegate.swift | 74 +++++++++++++++++++++++--- Sources/KeyboardShortcutSettings.swift | 19 ++++++- web/data/cmux.schema.json | 4 +- 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c9fc552c0c53..29da9c85701b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -83427,6 +83427,40 @@ } } }, + "shortcut.key.pageUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Page Up" + } + } + } + }, + "shortcut.key.pageDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Page Down" + } + } + } + }, "shortcut.pressShortcut.prompt": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a36f373a905c..916ca13a8584 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -702,6 +702,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var ghosttyGotoSplitRightShortcut: StoredShortcut? private var ghosttyGotoSplitUpShortcut: StoredShortcut? private var ghosttyGotoSplitDownShortcut: StoredShortcut? + private var ghosttyPreviousTabShortcut: StoredShortcut? + private var ghosttyNextTabShortcut: StoredShortcut? private var browserAddressBarFocusedPanelId: UUID? private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? @@ -1147,7 +1149,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.start() windowDecorationsController.start() installMainWindowKeyObserver() - refreshGhosttyGotoSplitShortcuts() + refreshGhosttyNavigationShortcuts() installGhosttyConfigObserver() installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() @@ -10527,7 +10529,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in - self?.refreshGhosttyGotoSplitShortcuts() + self?.refreshGhosttyNavigationShortcuts() } } @@ -10544,12 +10546,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } - private func refreshGhosttyGotoSplitShortcuts() { + private func refreshGhosttyNavigationShortcuts() { guard let config = GhosttyApp.shared.config else { ghosttyGotoSplitLeftShortcut = nil ghosttyGotoSplitRightShortcut = nil ghosttyGotoSplitUpShortcut = nil ghosttyGotoSplitDownShortcut = nil + ghosttyPreviousTabShortcut = nil + ghosttyNextTabShortcut = nil return } @@ -10565,6 +10569,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) ) + ghosttyPreviousTabShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "previous_tab", UInt("previous_tab".utf8.count)) + ) + ghosttyNextTabShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "next_tab", UInt("next_tab".utf8.count)) + ) } private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { @@ -10580,6 +10590,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent key = "↑" case GHOSTTY_KEY_ARROW_DOWN: key = "↓" + case GHOSTTY_KEY_PAGE_UP: + key = "pageUp" + case GHOSTTY_KEY_PAGE_DOWN: + key = "pageDown" case GHOSTTY_KEY_A: key = "a" case GHOSTTY_KEY_B: key = "b" case GHOSTTY_KEY_C: key = "c" @@ -10650,7 +10664,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) + let keyCode: UInt16? + if trigger.tag == GHOSTTY_TRIGGER_PHYSICAL { + keyCode = switch trigger.key.physical { + case GHOSTTY_KEY_PAGE_UP: + 116 + case GHOSTTY_KEY_PAGE_DOWN: + 121 + default: + nil + } + } else { + keyCode = nil + } + + return StoredShortcut( + key: key, + command: command, + shift: shift, + option: option, + control: control, + keyCode: keyCode + ) } private func handleQuitShortcutWarning() -> Bool { @@ -11333,7 +11368,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ - if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { + if matchConfiguredShortcut(event: event, action: .nextSidebarTab) || + shouldRouteGhosttyTabNavigationShortcut( + event: event, + shortcut: ghosttyNextTabShortcut, + preservingCmuxSurfaceAction: .nextSurface + ) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( @@ -11344,7 +11384,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { + if matchConfiguredShortcut(event: event, action: .prevSidebarTab) || + shouldRouteGhosttyTabNavigationShortcut( + event: event, + shortcut: ghosttyPreviousTabShortcut, + preservingCmuxSurfaceAction: .prevSurface + ) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( @@ -12622,6 +12667,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return matchConfiguredShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: action)) } + private func shouldRouteGhosttyTabNavigationShortcut( + event: NSEvent, + shortcut: StoredShortcut?, + preservingCmuxSurfaceAction surfaceAction: KeyboardShortcutSettings.Action + ) -> Bool { + guard let shortcut, + matchShortcut(event: event, shortcut: shortcut) else { + return false + } + + // Ghostty's macOS defaults use Cmd+Shift+[/] for previous/next tab, + // which cmux owns as surface navigation. Keep cmux's active surface + // shortcut authoritative, while allowing user Ghostty tab bindings + // such as Cmd+Option+Arrow or Ctrl+PageUp/PageDown to drive workspaces. + return !matchConfiguredShortcut(event: event, action: surfaceAction) + } + fileprivate func shouldForwardBrowserSurfaceShortcutToTerminal(_ event: NSEvent) -> Bool { return KeyboardShortcutSettings.Action.allCases.contains { $0.shortcutContext == .browserPanel && diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f34776d4fe18..bc503e794afb 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -1178,6 +1178,10 @@ struct ShortcutStroke: Equatable, Hashable { case "\t": return String(localized: "shortcut.key.tab", defaultValue: "Tab") case "space": return String(localized: "shortcut.key.space", defaultValue: "Space") + case "pageUp", "pageup": + return String(localized: "shortcut.key.pageUp", defaultValue: "Page Up") + case "pageDown", "pagedown": + return String(localized: "shortcut.key.pageDown", defaultValue: "Page Down") case "\r": return "↩" case "media.brightnessDown": @@ -1490,6 +1494,8 @@ struct ShortcutStroke: Equatable, Hashable { case 126: return "↑" // up arrow case 48: return "\t" // tab case 49: return "space" // kVK_Space + case 116: return "pageUp" // kVK_PageUp + case 121: return "pageDown" // kVK_PageDown case 36, 76: return "\r" // return, keypad enter case 33: return "[" // kVK_ANSI_LeftBracket case 30: return "]" // kVK_ANSI_RightBracket @@ -1658,6 +1664,8 @@ struct ShortcutStroke: Equatable, Hashable { case "media.next": return 17 case "media.previous": return 18 case "space": return 49 + case "pageup": return 116 + case "pagedown": return 121 case "a": return 0 case "s": return 1 case "d": return 2 @@ -1717,7 +1725,12 @@ struct ShortcutStroke: Equatable, Hashable { } private static func usesDirectKeyCodeMatching(_ key: String) -> Bool { - key == "space" || functionKeyDisplayString(for: key) != nil || key.hasPrefix("media.") + let normalized = key.lowercased() + return normalized == "space" || + normalized == "pageup" || + normalized == "pagedown" || + functionKeyDisplayString(for: normalized) != nil || + normalized.hasPrefix("media.") } private static func functionKeyDisplayString(for key: String) -> String? { @@ -2096,6 +2109,10 @@ extension ShortcutStroke { return "\r" case "space", "spacebar", "": return "space" + case "pageup", "page_up", "page-up", "": + return "pageUp" + case "pagedown", "page_down", "page-down", "": + return "pageDown" case "comma": return "," case "period", "dot": diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 6476c9081112..3ea5fb069344 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -867,8 +867,8 @@ }, "shortcutStroke": { "type": "string", - "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$", - "description": "One keyboard shortcut stroke using modifier+key syntax. Supported key names include space, Space, , and ." + "pattern": "^(?:(?:[cC][mM][dD]|[cC][oO][mM][mM][aA][nN][dD]|[sS][hH][iI][fF][tT]|[oO][pP][tT]|[oO][pP][tT][iI][oO][nN]|[aA][lL][tT]|[cC][tT][rR][lL]|[cC][oO][nN][tT][rR][oO][lL]|[cC][tT][lL]|⌘|⇧|⌥|⌃)\\+)*(?: |[A-Za-z0-9]|[lL][eE][fF][tT]|[aA][rR][rR][oO][wW][lL][eE][fF][tT]|[lL][eE][fF][tT][aA][rR][rR][oO][wW]|←|[rR][iI][gG][hH][tT]|[aA][rR][rR][oO][wW][rR][iI][gG][hH][tT]|[rR][iI][gG][hH][tT][aA][rR][rR][oO][wW]|→|[uU][pP]|[aA][rR][rR][oO][wW][uU][pP]|[uU][pP][aA][rR][rR][oO][wW]|↑|[dD][oO][wW][nN]|[aA][rR][rR][oO][wW][dD][oO][wW][nN]|[dD][oO][wW][nN][aA][rR][rR][oO][wW]|↓|[pP][aA][gG][eE][uU][pP]|[pP][aA][gG][eE]_[uU][pP]|[pP][aA][gG][eE]-[uU][pP]|<[pP][aA][gG][eE][uU][pP]>|[pP][aA][gG][eE][dD][oO][wW][nN]|[pP][aA][gG][eE]_[dD][oO][wW][nN]|[pP][aA][gG][eE]-[dD][oO][wW][nN]|<[pP][aA][gG][eE][dD][oO][wW][nN]>|[tT][aA][bB]|[rR][eE][tT][uU][rR][nN]|[eE][nN][tT][eE][rR]|↩|[sS][pP][aA][cC][eE]|[sS][pP][aA][cC][eE][bB][aA][rR]|<[sS][pP][aA][cC][eE]>|[,./\\\\;'`=\\[\\]-]|[cC][oO][mM][mM][aA]|[pP][eE][rR][iI][oO][dD]|[dD][oO][tT]|[sS][lL][aA][sS][hH]|[bB][aA][cC][kK][sS][lL][aA][sS][hH]|[sS][eE][mM][iI][cC][oO][lL][oO][nN]|[qQ][uU][oO][tT][eE]|[aA][pP][oO][sS][tT][rR][oO][pP][hH][eE]|[bB][aA][cC][kK][tT][iI][cC][kK]|[gG][rR][aA][vV][eE]|[mM][iI][nN][uU][sS]|[hH][yY][pP][hH][eE][nN]|[pP][lL][uU][sS]|[eE][qQ][uU][aA][lL][sS]|[lL][eE][fF][tT][bB][rR][aA][cC][kK][eE][tT]|[oO][pP][eE][nN][bB][rR][aA][cC][kK][eE][tT]|[rR][iI][gG][hH][tT][bB][rR][aA][cC][kK][eE][tT]|[cC][lL][oO][sS][eE][bB][rR][aA][cC][kK][eE][tT]|[fF](?:[1-9]|1[0-9]|20)|[vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][uU][pP]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][uU][pP]|[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[vV][oO][lL][uU][mM][eE][dD][oO][wW][nN]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][uU][pP]|[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA][bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][eE][dD][iI][aA]\\.[bB][rR][iI][gG][hH][tT][nN][eE][sS][sS][dD][oO][wW][nN]|[mM][uU][tT][eE]|[mM][eE][dD][iI][aA][mM][uU][tT][eE]|[mM][eE][dD][iI][aA]\\.[mM][uU][tT][eE]|[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA][pP][lL][aA][yY][pP][aA][uU][sS][eE]|[mM][eE][dD][iI][aA]\\.[pP][lL][aA][yY][pP][aA][uU][sS][eE]|[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT]|[mM][eE][dD][iI][aA]\\.[nN][eE][xX][tT][tT][rR][aA][cC][kK]|[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK]|[mM][eE][dD][iI][aA][pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS]|[mM][eE][dD][iI][aA]\\.[pP][rR][eE][vV][iI][oO][uU][sS][tT][rR][aA][cC][kK])$", + "description": "One keyboard shortcut stroke using modifier+key syntax. Supported key names include space, Space, , , pageUp, pageDown, page_up, and page_down." } } } From 8f3a983abe371fc4e7221d9d8075b4ae8c5f669f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 12:16:42 -0700 Subject: [PATCH 3/8] fix: avoid shortcut switch expression --- Sources/AppDelegate.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 916ca13a8584..d842c584fb63 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10666,13 +10666,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let keyCode: UInt16? if trigger.tag == GHOSTTY_TRIGGER_PHYSICAL { - keyCode = switch trigger.key.physical { + switch trigger.key.physical { case GHOSTTY_KEY_PAGE_UP: - 116 + keyCode = 116 case GHOSTTY_KEY_PAGE_DOWN: - 121 + keyCode = 121 default: - nil + keyCode = nil } } else { keyCode = nil From 191e09f3debd5f13aafe8980acf44783db2c5a5e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 12:20:06 -0700 Subject: [PATCH 4/8] fix: address shortcut review feedback --- Resources/Localizable.xcstrings | 4 ++-- cmuxTests/AppDelegateShortcutRoutingTests.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 281a98146cc8..93744e2dd98e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -83436,7 +83436,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "Page Up" + "value": "ページアップ" } } } @@ -83453,7 +83453,7 @@ "ja": { "stringUnit": { "state": "translated", - "value": "Page Down" + "value": "ページダウン" } } } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index f445c867b903..5dce965938bd 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -141,6 +141,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { source: "test.restoreGhosttyConfig", reloadSettingsFromFile: false ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) try? fileManager.removeItem(at: root) } From 7acfc85f83b825af014cc5b5165914c7214cdda0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 26 May 2026 11:55:42 -0700 Subject: [PATCH 5/8] fix: order page key localizations --- Resources/Localizable.xcstrings | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 42a13fb4d0fc..190689f05823 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -88252,36 +88252,36 @@ } } }, - "shortcut.key.pageUp": { + "shortcut.key.pageDown": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Page Up" + "value": "Page Down" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ページアップ" + "value": "ページダウン" } } } }, - "shortcut.key.pageDown": { + "shortcut.key.pageUp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Page Down" + "value": "Page Up" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ページダウン" + "value": "ページアップ" } } } From bb2d6c9932f11998e42b2d85ea6f0c1dd6a4fc4b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 26 May 2026 12:04:10 -0700 Subject: [PATCH 6/8] fix: silence main actor shortcut refresh warning --- Sources/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9bd51952b17f..f1bd612a667c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1218,7 +1218,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.start() windowDecorationsController.start() installMainWindowKeyObserver() - refreshGhosttyNavigationShortcuts() + MainActor.assumeIsolated { + refreshGhosttyNavigationShortcuts() + } installGhosttyConfigObserver() installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() From 8e7bdf2434b1e01f7b9764a73296aad163440f95 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 26 May 2026 12:08:59 -0700 Subject: [PATCH 7/8] fix: bridge Ghostty config observer to main actor --- Sources/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f1bd612a667c..f210c0de97d6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11567,7 +11567,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in - self?.refreshGhosttyNavigationShortcuts() + MainActor.assumeIsolated { + self?.refreshGhosttyNavigationShortcuts() + } } } From 3da86d7c7574f0ee0a76ed81ed680aea7fe4a09d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 26 May 2026 14:13:07 -0700 Subject: [PATCH 8/8] fix: localize page key labels --- Resources/Localizable.xcstrings | 216 ++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 190689f05823..10ba545fe42a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -88255,34 +88255,250 @@ "shortcut.key.pageDown": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "صفحة لأسفل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Stranica dolje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Side ned" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bild ab" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Page Down" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Av Pág" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Page suivante" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Pagina giù" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ページダウン" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំព័រចុះ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 아래로" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Side ned" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Strona w dół" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Página abaixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Страница вниз" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลื่อนหน้าลง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sayfa Aşağı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сторінка вниз" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下一页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下一頁" + } } } }, "shortcut.key.pageUp": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "صفحة لأعلى" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Stranica gore" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Side op" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bild auf" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Page Up" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Re Pág" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Page précédente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Pagina su" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ページアップ" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំព័រឡើង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "페이지 위로" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Side opp" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Strona w górę" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Página acima" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Страница вверх" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลื่อนหน้าขึ้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sayfa Yukarı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сторінка вгору" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上一頁" + } } } },