From 0ceaef136ab34fb8d07af5e7eeb9e45610a5121c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 23 May 2026 20:05:39 -0700 Subject: [PATCH 01/29] Add tmux split resize shortcuts --- Resources/Localizable.xcstrings | 68 ++++++++++++++ Sources/AppDelegate.swift | 82 +++++++++++++++++ Sources/KeyboardShortcutSettings.swift | 31 +++++++ Sources/TabManager+CompatibilityTypes.swift | 4 + Sources/TabManager.swift | 17 +++- ...pDelegateEqualizeSplitsShortcutTests.swift | 91 +++++++++++++++++++ ...dShortcutSettingsEqualizeSplitsTests.swift | 62 +++++++++++++ web/app/[locale]/docs/configuration/page.tsx | 18 +++- .../[locale]/docs/keyboard-shortcuts/page.tsx | 1 + web/app/[locale]/keyboard-shortcuts.tsx | 40 +++++++- web/data/cmux-shortcuts.ts | 11 +++ web/data/cmux.schema.json | 4 + 12 files changed, 421 insertions(+), 8 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 528fb35c76c6..211409c280e2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -86709,6 +86709,74 @@ } } }, + "shortcut.resizeSplitDown.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resize Split Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割を下にリサイズ" + } + } + } + }, + "shortcut.resizeSplitLeft.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resize Split Left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割を左にリサイズ" + } + } + } + }, + "shortcut.resizeSplitRight.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resize Split Right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割を右にリサイズ" + } + } + } + }, + "shortcut.resizeSplitUp.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resize Split Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分割を上にリサイズ" + } + } + } + }, "shortcut.splitRight.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5815589b53eb..d07c4ec55ca7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -12091,6 +12091,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) { + _ = performResizeSplitShortcut( + direction: .left, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + + if matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) { + _ = performResizeSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + + if matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) { + _ = performResizeSplitShortcut( + direction: .up, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + + if matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) { + _ = performResizeSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager _ = routedManager?.toggleFocusedSplitZoom() @@ -12983,6 +13035,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } + @discardableResult + func performResizeSplitShortcut( + direction: ResizeDirection, + preferredWindow: NSWindow? = nil + ) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + let routedManager = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) + + if let terminalContext { + if shouldSuppressSplitShortcutForTransientTerminalFocusState(tabManager: terminalContext.tabManager) { + return true + } + return terminalContext.tabManager.resizeSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction, + amount: SplitResizeShortcutDefaults.stepPixels + ) + } + + if shouldSuppressSplitShortcutForTransientTerminalFocusState(tabManager: routedManager) { + return true + } + return routedManager?.resizeFocusedSplit( + direction: direction, + amount: SplitResizeShortcutDefaults.stepPixels + ) ?? false + } + @discardableResult func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index a749fb7d5fac..233dc4e3205e 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -112,6 +112,10 @@ enum KeyboardShortcutSettings { case focusRight case focusUp case focusDown + case resizeSplitLeft + case resizeSplitRight + case resizeSplitUp + case resizeSplitDown case splitRight case splitDown, toggleSplitZoom case equalizeSplits @@ -193,6 +197,10 @@ 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 .resizeSplitLeft: return String(localized: "shortcut.resizeSplitLeft.label", defaultValue: "Resize Split Left") + case .resizeSplitRight: return String(localized: "shortcut.resizeSplitRight.label", defaultValue: "Resize Split Right") + case .resizeSplitUp: return String(localized: "shortcut.resizeSplitUp.label", defaultValue: "Resize Split Up") + case .resizeSplitDown: return String(localized: "shortcut.resizeSplitDown.label", defaultValue: "Resize Split Down") 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") @@ -324,6 +332,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) + case .resizeSplitLeft: + return Self.tmuxSplitResizeShortcut(chordKey: "←") + case .resizeSplitRight: + return Self.tmuxSplitResizeShortcut(chordKey: "→") + case .resizeSplitUp: + return Self.tmuxSplitResizeShortcut(chordKey: "↑") + case .resizeSplitDown: + return Self.tmuxSplitResizeShortcut(chordKey: "↓") 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) @@ -388,6 +404,21 @@ enum KeyboardShortcutSettings { } } + private static func tmuxSplitResizeShortcut(chordKey: String) -> StoredShortcut { + StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: chordKey, + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + } + func tooltip(_ base: String) -> String { "\(base) (\(displayedShortcutString(for: KeyboardShortcutSettings.shortcut(for: self))))" } diff --git a/Sources/TabManager+CompatibilityTypes.swift b/Sources/TabManager+CompatibilityTypes.swift index 4569e8e03985..398790482b67 100644 --- a/Sources/TabManager+CompatibilityTypes.swift +++ b/Sources/TabManager+CompatibilityTypes.swift @@ -52,3 +52,7 @@ enum ResizeDirection { requiresPaneInFirstChild ? 1 : -1 } } + +enum SplitResizeShortcutDefaults { + static let stepPixels: UInt16 = 120 +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 87b9f79f3b7b..f499b34f0a17 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5861,7 +5861,22 @@ class TabManager: ObservableObject { let delta = CGFloat(amount) / candidate.axisPixels let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) let clamped = min(max(requested, 0.1), 0.9) - return tab.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) + guard tab.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else { + return false + } + tab.didProgrammaticallyChangeSplitGeometry() + return true + } + + /// Resize the currently focused split edge in the selected workspace. + @discardableResult + func resizeFocusedSplit( + direction: ResizeDirection, + amount: UInt16 = SplitResizeShortcutDefaults.stepPixels + ) -> Bool { + guard let tab = selectedWorkspace, + let focusedPanelId = tab.focusedPanelId else { return false } + return resizeSplit(tabId: tab.id, surfaceId: focusedPanelId, direction: direction, amount: amount) } /// Toggle zoom on a panel. diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 302525c1da99..3cf7b2daffa3 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -145,6 +145,97 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { shortcutRoutingAssertPaneFramesMatch(cachedEqualizedLayout, liveEqualizedLayout) } + func testTmuxStyleResizeSplitShortcutMovesFocusedDividerAndUpdatesCachedLayout() { + withTemporaryShortcut(action: .resizeSplitRight) { + 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 workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node") + return + } + + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + workspace.splitTabBar( + workspace.bonsplitController, + didChangeGeometry: workspace.bonsplitController.layoutSnapshot() + ) + guard let seededLayoutSnapshot = workspace.tmuxLayoutSnapshot else { + XCTFail("Expected cached layout snapshot after seeding split geometry") + return + } + + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ), + let resizeEvent = makeKeyDownEvent( + key: "→", + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct tmux resize shortcut events") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + guard let armedSplit = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected split node after arming chord") + return + } + XCTAssertEqual( + armedSplit.dividerPosition, + 0.5, + accuracy: 0.000_1 + ) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + + let liveResizedLayout = workspace.bonsplitController.layoutSnapshot() + guard let cachedResizedLayout = workspace.tmuxLayoutSnapshot else { + XCTFail("Expected cached layout snapshot after resizing split geometry") + return + } + XCTAssertNotEqual( + shortcutRoutingPaneFramesById(in: seededLayoutSnapshot), + shortcutRoutingPaneFramesById(in: liveResizedLayout) + ) + shortcutRoutingAssertPaneFramesMatch(cachedResizedLayout, liveResizedLayout) + } + } + private func shortcutRoutingSplitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { switch node { case .pane: diff --git a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift index dc4937772fff..52fb76e28129 100644 --- a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift @@ -7,6 +7,23 @@ import XCTest #endif final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { + func testResizeSplitDefaultsUseTmuxStyleArrowChords() { + let expected: [(KeyboardShortcutSettings.Action, String)] = [ + (.resizeSplitLeft, "←"), + (.resizeSplitRight, "→"), + (.resizeSplitUp, "↑"), + (.resizeSplitDown, "↓"), + ] + + for (action, chordKey) in expected { + XCTAssertEqual( + action.defaultShortcut, + tmuxResizeShortcut(chordKey: chordKey), + "Expected \(action.rawValue) to use Ctrl+B then Option+\(chordKey)" + ) + } + } + func testSettingsFileStoreParsesEqualizeSplitsShortcut() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -35,6 +52,36 @@ final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { ) } + func testSettingsFileStoreParsesResizeSplitShortcutChord() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "bindings": { + "resizeSplitRight": ["ctrl+b", "alt+right"] + } + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .resizeSplitRight), + tmuxResizeShortcut(chordKey: "→") + ) + } + func testSettingsFileStoreParsesSystemWideHotkeyWithoutSharedStoreRecursion() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -73,4 +120,19 @@ final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { private func writeSettingsFile(_ contents: String, to url: URL) throws { try contents.data(using: .utf8)?.write(to: url) } + + private func tmuxResizeShortcut(chordKey: String) -> StoredShortcut { + StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: chordKey, + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + } } diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 60077d6421ed..0a558d5b1d1f 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -5,7 +5,12 @@ import { Link } from "../../../../i18n/navigation"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; import settingsSchema from "../../../../data/cmux.schema.json"; -import { shortcutCategories, type LocalizedText } from "../../../../data/cmux-shortcuts"; +import { + shortcutCategories, + shortcutSequences, + type LocalizedText, + type ShortcutSequence, +} from "../../../../data/cmux-shortcuts"; import { DocsHeading } from "../../components/docs-heading"; type SchemaProperty = { @@ -85,6 +90,7 @@ const settingsFileExample = `{ // "toggleSidebar": "cmd+b", // "toggleFileExplorer": "cmd+opt+b", // "newTab": ["ctrl+b", "c"], + // "resizeSplitRight": ["ctrl+b", "alt+right"], // "commandPalettePrevious": null // } // }, @@ -125,6 +131,14 @@ function shortcutComboToConfig(combo: string[]) { .join("+"); } +function shortcutSequenceToConfig(sequence: ShortcutSequence) { + const strokes = sequence.map(shortcutComboToConfig).filter(Boolean); + if (strokes.length <= 1) { + return strokes[0] ?? ""; + } + return JSON.stringify(strokes); +} + function formatSchemaType(property: SchemaProperty): string { if (property.oneOf?.length) { return property.oneOf.map(formatSchemaType).join(" | "); @@ -398,7 +412,7 @@ working-directory = ~/code`}
Default file value
- {shortcutComboToConfig(shortcut.combos[0] ?? [])} + {shortcutSequenceToConfig(shortcutSequences(shortcut)[0] ?? [])}
))} diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 3c1d6a170642..d739427ae359 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -12,6 +12,7 @@ const shortcutChordExample = `{ "bindings": { "newSurface": ["ctrl+b", "c"], "showNotifications": ["ctrl+b", "i"], + "resizeSplitRight": ["ctrl+b", "alt+right"], "toggleSidebar": "cmd+b", "toggleFileExplorer": "cmd+opt+b", "splitRight": "", diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index 6072d4e037f7..81914c78d8c4 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -2,7 +2,13 @@ import { useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { shortcutCategories, type LocalizedText, type Shortcut } from "../../data/cmux-shortcuts"; +import { + shortcutCategories, + shortcutSequences, + type LocalizedText, + type Shortcut, + type ShortcutSequence, +} from "../../data/cmux-shortcuts"; function localizedText(text: LocalizedText, locale: string) { return locale.startsWith("ja") ? text.ja : text.en; @@ -16,6 +22,10 @@ function comboToText(combo: string[]) { return combo.join(" "); } +function sequenceToText(sequence: ShortcutSequence) { + return sequence.map(comboToText).join(" then "); +} + function KeyCombo({ combo }: { combo: string[] }) { return ( @@ -33,9 +43,29 @@ function KeyCombo({ combo }: { combo: string[] }) { ); } +function KeySequence({ sequence, locale }: { sequence: ShortcutSequence; locale: string }) { + const separator = locale.startsWith("ja") ? "次に" : "then"; + + return ( + + {sequence.map((combo, idx) => ( + + {idx > 0 && ( + + {separator} + + )} + + + ))} + + ); +} + function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string }) { const description = localizedText(shortcut.description, locale); const note = shortcut.note ? localizedText(shortcut.note, locale) : undefined; + const sequences = shortcutSequences(shortcut); return (
@@ -44,14 +74,14 @@ function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string {note && {note}}
- {shortcut.combos.map((combo, idx) => ( - + {sequences.map((sequence, idx) => ( + {idx > 0 && ( / )} - + ))}
@@ -75,7 +105,7 @@ export function KeyboardShortcuts() { const catTitle = t(`cat.${cat.titleKey}`); const description = localizedText(shortcut.description, locale); const note = shortcut.note ? localizedText(shortcut.note, locale) : ""; - const combos = shortcut.combos.map(comboToText).join(" "); + const combos = shortcutSequences(shortcut).map(sequenceToText).join(" "); return normalize(`${catTitle} ${combos} ${description} ${note}`).includes(q); }), })).filter((cat) => cat.shortcuts.length > 0); diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 4dff87f634b3..d15593f541b6 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -3,9 +3,12 @@ export type LocalizedText = { ja: string; }; +export type ShortcutSequence = string[][]; + export type Shortcut = { id: string; combos: string[][]; + chordCombos?: ShortcutSequence[]; description: LocalizedText; note?: LocalizedText; }; @@ -17,6 +20,10 @@ export type ShortcutCategory = { shortcuts: Shortcut[]; }; +export function shortcutSequences(shortcut: Shortcut): ShortcutSequence[] { + return shortcut.chordCombos ?? shortcut.combos.map((combo) => [combo]); +} + export const shortcutCategories: ShortcutCategory[] = [ { id: "app", @@ -130,6 +137,10 @@ 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: "resizeSplitLeft", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "←"]]], description: { en: "Resize split left", ja: "分割を左にリサイズ" } }, + { id: "resizeSplitRight", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "→"]]], description: { en: "Resize split right", ja: "分割を右にリサイズ" } }, + { id: "resizeSplitUp", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "↑"]]], description: { en: "Resize split up", ja: "分割を上にリサイズ" } }, + { id: "resizeSplitDown", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "↓"]]], description: { en: "Resize split down", ja: "分割を下にリサイズ" } }, { 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 4c7289bf9283..a90c2ee444eb 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -831,6 +831,10 @@ "focusRight", "focusUp", "focusDown", + "resizeSplitLeft", + "resizeSplitRight", + "resizeSplitUp", + "resizeSplitDown", "splitRight", "splitDown", "toggleSplitZoom", From f953934af74f576c53ebd3cdb64c53e62782bfa9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 23 May 2026 20:18:11 -0700 Subject: [PATCH 02/29] Address resize shortcut review feedback --- Resources/Localizable.xcstrings | 408 ++++++++++++++++++ Sources/AppDelegate.swift | 39 +- ...pDelegateEqualizeSplitsShortcutTests.swift | 10 + web/app/[locale]/keyboard-shortcuts.tsx | 34 +- web/messages/ar.json | 1 + web/messages/bs.json | 1 + web/messages/da.json | 1 + web/messages/de.json | 1 + web/messages/en.json | 1 + web/messages/es.json | 1 + web/messages/fr.json | 1 + web/messages/it.json | 1 + web/messages/ja.json | 1 + web/messages/km.json | 1 + web/messages/ko.json | 1 + web/messages/no.json | 1 + web/messages/pl.json | 1 + web/messages/pt-BR.json | 1 + web/messages/ru.json | 1 + web/messages/th.json | 1 + web/messages/tr.json | 1 + web/messages/uk.json | 1 + web/messages/zh-CN.json | 1 + web/messages/zh-TW.json | 1 + 24 files changed, 497 insertions(+), 14 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 211409c280e2..158e720e16a3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -86712,68 +86712,476 @@ "shortcut.resizeSplitDown.label": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيير حجم التقسيم للأسفل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Promijeni veličinu podjele dolje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpas opdeling nedad" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilung nach unten anpassen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Resize Split Down" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar división hacia abajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redimensionner la division vers le bas" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ridimensiona divisione in basso" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "分割を下にリサイズ" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "분할을 아래로 크기 조절" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endre deling nedover" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień rozmiar podziału w dół" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar Divisão para Baixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить размер разделения вниз" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปรับขนาดการแบ่งลงด้านล่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmeyi Aşağı Yeniden Boyutlandır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Змінити розмір розділення вниз" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向下调整分割" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向下調整分割" + } } } }, "shortcut.resizeSplitLeft.label": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيير حجم التقسيم لليسار" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Promijeni veličinu podjele lijevo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpas opdeling mod venstre" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilung nach links anpassen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Resize Split Left" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar división a la izquierda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redimensionner la division vers la gauche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ridimensiona divisione a sinistra" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "分割を左にリサイズ" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "분할을 왼쪽으로 크기 조절" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endre deling mot venstre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień rozmiar podziału w lewo" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar Divisão à Esquerda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить размер разделения влево" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปรับขนาดการแบ่งไปทางซ้าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmeyi Sola Yeniden Boyutlandır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Змінити розмір розділення ліворуч" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向左调整分割" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向左調整分割" + } } } }, "shortcut.resizeSplitRight.label": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيير حجم التقسيم لليمين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Promijeni veličinu podjele desno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpas opdeling mod højre" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilung nach rechts anpassen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Resize Split Right" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar división a la derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redimensionner la division vers la droite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ridimensiona divisione a destra" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "分割を右にリサイズ" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "분할을 오른쪽으로 크기 조절" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endre deling mot høyre" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień rozmiar podziału w prawo" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar Divisão à Direita" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить размер разделения вправо" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปรับขนาดการแบ่งไปทางขวา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmeyi Sağa Yeniden Boyutlandır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Змінити розмір розділення праворуч" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向右调整分割" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向右調整分割" + } } } }, "shortcut.resizeSplitUp.label": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيير حجم التقسيم للأعلى" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Promijeni veličinu podjele gore" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilpas opdeling opad" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teilung nach oben anpassen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Resize Split Up" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar división hacia arriba" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redimensionner la division vers le haut" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ridimensiona divisione in alto" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "分割を上にリサイズ" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "분할을 위로 크기 조절" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Endre deling oppover" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zmień rozmiar podziału w górę" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Redimensionar Divisão para Cima" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Изменить размер разделения вверх" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปรับขนาดการแบ่งขึ้นด้านบน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölmeyi Yukarı Yeniden Boyutlandır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Змінити розмір розділення вгору" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向上调整分割" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向上調整分割" + } } } }, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d07c4ec55ca7..f8c7c57e64b5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11117,6 +11117,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } +#if DEBUG + func debugSetGhosttyGotoSplitShortcut( + _ shortcut: StoredShortcut?, + direction: NavigationDirection + ) { + switch direction { + case .left: + ghosttyGotoSplitLeftShortcut = shortcut + case .right: + ghosttyGotoSplitRightShortcut = shortcut + case .up: + ghosttyGotoSplitUpShortcut = shortcut + case .down: + ghosttyGotoSplitDownShortcut = shortcut + } + } +#endif + private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { let key: String switch trigger.tag { @@ -12042,12 +12060,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). + let shouldMatchGhosttyGotoSplitFallback = activeConfiguredShortcutChordPrefixForCurrentEvent == nil if matchConfiguredDirectionalShortcut( event: event, action: .focusLeft, arrowGlyph: "←", arrowKeyCode: 123 - ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) + ) { cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .left) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .left) @@ -12059,7 +12081,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: .focusRight, arrowGlyph: "→", arrowKeyCode: 124 - ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) + ) { cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .right) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .right) @@ -12071,7 +12096,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: .focusUp, arrowGlyph: "↑", arrowKeyCode: 126 - ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) + ) { cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .up) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .up) @@ -12083,7 +12111,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: .focusDown, arrowGlyph: "↓", arrowKeyCode: 125 - ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { + ) || ( + shouldMatchGhosttyGotoSplitFallback && + (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) + ) { cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.movePaneFocus(direction: .down) #if DEBUG recordGotoSplitMoveIfNeeded(direction: .down) diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 3cf7b2daffa3..7595a9accd80 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -184,6 +184,16 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { return } +#if DEBUG + appDelegate.debugSetGhosttyGotoSplitShortcut( + StoredShortcut(key: "→", command: false, shift: false, option: true, control: false), + direction: .right + ) + defer { + appDelegate.debugSetGhosttyGotoSplitShortcut(nil, direction: .right) + } +#endif + guard let prefixEvent = makeKeyDownEvent( key: "b", modifiers: [.control], diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index 81914c78d8c4..e05470f6e734 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -22,8 +22,8 @@ function comboToText(combo: string[]) { return combo.join(" "); } -function sequenceToText(sequence: ShortcutSequence) { - return sequence.map(comboToText).join(" then "); +function sequenceToText(sequence: ShortcutSequence, separator: string) { + return sequence.map(comboToText).join(` ${separator} `); } function KeyCombo({ combo }: { combo: string[] }) { @@ -43,9 +43,7 @@ function KeyCombo({ combo }: { combo: string[] }) { ); } -function KeySequence({ sequence, locale }: { sequence: ShortcutSequence; locale: string }) { - const separator = locale.startsWith("ja") ? "次に" : "then"; - +function KeySequence({ sequence, separator }: { sequence: ShortcutSequence; separator: string }) { return ( {sequence.map((combo, idx) => ( @@ -62,7 +60,15 @@ function KeySequence({ sequence, locale }: { sequence: ShortcutSequence; locale: ); } -function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string }) { +function ShortcutRow({ + shortcut, + locale, + sequenceSeparator, +}: { + shortcut: Shortcut; + locale: string; + sequenceSeparator: string; +}) { const description = localizedText(shortcut.description, locale); const note = shortcut.note ? localizedText(shortcut.note, locale) : undefined; const sequences = shortcutSequences(shortcut); @@ -81,7 +87,7 @@ function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string / )} - +
))} @@ -93,6 +99,7 @@ export function KeyboardShortcuts() { const [query, setQuery] = useState(""); const locale = useLocale(); const t = useTranslations("docs.keyboardShortcuts"); + const sequenceSeparator = t("sequenceSeparator"); const trimmedQuery = query.trim(); @@ -105,11 +112,13 @@ export function KeyboardShortcuts() { const catTitle = t(`cat.${cat.titleKey}`); const description = localizedText(shortcut.description, locale); const note = shortcut.note ? localizedText(shortcut.note, locale) : ""; - const combos = shortcutSequences(shortcut).map(sequenceToText).join(" "); + const combos = shortcutSequences(shortcut) + .map((sequence) => sequenceToText(sequence, sequenceSeparator)) + .join(" "); return normalize(`${catTitle} ${combos} ${description} ${note}`).includes(q); }), })).filter((cat) => cat.shortcuts.length > 0); - }, [locale, query, t]); + }, [locale, query, sequenceSeparator, t]); return (
@@ -178,7 +187,12 @@ export function KeyboardShortcuts() {
{cat.shortcuts.map((shortcut) => ( - + ))}
diff --git a/web/messages/ar.json b/web/messages/ar.json index 75a7669d032e..60d949288077 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "اختصارات لوحة المفاتيح", "description": "جميع اختصارات لوحة المفاتيح المتاحة في cmux، مجمعة حسب الفئة.", + "sequenceSeparator": "ثم", "metaDescription": "جميع اختصارات لوحة مفاتيح cmux لمساحات العمل والأسطح والألواح المقسمة والمتصفح والإشعارات والبحث وإدارة النوافذ على macOS.", "searchPlaceholder": "بحث في الاختصارات...", "searchLabel": "بحث اختصارات لوحة المفاتيح", diff --git a/web/messages/bs.json b/web/messages/bs.json index d651461f5f91..40d9482fd80d 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Prečice na tastaturi", "description": "Sve prečice na tastaturi dostupne u cmux-u, grupirane po kategorijama.", + "sequenceSeparator": "zatim", "metaDescription": "Sve cmux prečice na tastaturi za radne prostore, površine, podijeljene panele, preglednik, notifikacije, pretragu i upravljanje prozorima na macOS-u.", "searchPlaceholder": "Pretraži prečice...", "searchLabel": "Pretraži prečice na tastaturi", diff --git a/web/messages/da.json b/web/messages/da.json index dae091d20330..64e7e974f2b1 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Tastaturgenveje", "description": "Alle tastaturgenveje tilgængelige i cmux, grupperet efter kategori.", + "sequenceSeparator": "derefter", "metaDescription": "Alle cmux-tastaturgenveje til workspaces, surfaces, opdelte paneler, browser, notifikationer, søgning og vindueshåndtering på macOS.", "searchPlaceholder": "Søg efter genveje...", "searchLabel": "Søg i tastaturgenveje", diff --git a/web/messages/de.json b/web/messages/de.json index 1aeb6575e403..9fe33a7a0085 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Tastaturkürzel", "description": "Alle in cmux verfügbaren Tastaturkürzel, nach Kategorie gruppiert.", + "sequenceSeparator": "dann", "metaDescription": "Alle cmux-Tastaturkürzel für Workspaces, Oberflächen, geteilte Bereiche, Browser, Benachrichtigungen, Suche und Fensterverwaltung unter macOS.", "searchPlaceholder": "Kürzel suchen...", "searchLabel": "Tastaturkürzel durchsuchen", diff --git a/web/messages/en.json b/web/messages/en.json index e1516354f285..fcf3f9c65b17 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -531,6 +531,7 @@ "keyboardShortcuts": { "title": "Keyboard Shortcuts", "description": "Default cmux keyboard shortcuts. Every cmux-owned shortcut can be changed in Settings or ~/.config/cmux/cmux.json, including two-step chords.", + "sequenceSeparator": "then", "metaTitle": "Keyboard Shortcuts", "metaDescription": "Default cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and app controls on macOS. All cmux-owned shortcuts are configurable in Settings or cmux.json.", "chordsTitle": "Shortcut chords", diff --git a/web/messages/es.json b/web/messages/es.json index c3910d711b0c..738a44cdcacb 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Atajos de teclado", "description": "Todos los atajos de teclado disponibles en cmux, agrupados por categoría.", + "sequenceSeparator": "luego", "metaDescription": "Todos los atajos de teclado de cmux para workspaces, superficies, paneles divididos, navegador, notificaciones, búsqueda y gestión de ventanas en macOS.", "searchPlaceholder": "Buscar atajos...", "searchLabel": "Buscar atajos de teclado", diff --git a/web/messages/fr.json b/web/messages/fr.json index 6ec2d9e168d7..eaf74d531cd1 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Raccourcis clavier", "description": "Tous les raccourcis clavier disponibles dans cmux, classés par catégorie.", + "sequenceSeparator": "puis", "metaDescription": "Tous les raccourcis clavier cmux pour les espaces de travail, surfaces, panneaux divisés, navigateur, notifications, recherche et gestion des fenêtres sur macOS.", "searchPlaceholder": "Rechercher des raccourcis...", "searchLabel": "Rechercher les raccourcis clavier", diff --git a/web/messages/it.json b/web/messages/it.json index 910757b25d17..ce6ce24b4b96 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Scorciatoie da tastiera", "description": "Tutte le scorciatoie da tastiera disponibili in cmux, raggruppate per categoria.", + "sequenceSeparator": "poi", "metaDescription": "Tutte le scorciatoie da tastiera cmux per workspace, superfici, pannelli divisi, browser, notifiche, ricerca e gestione delle finestre su macOS.", "searchPlaceholder": "Cerca scorciatoie...", "searchLabel": "Cerca scorciatoie da tastiera", diff --git a/web/messages/ja.json b/web/messages/ja.json index b28010ba57af..fbe7fecb93c8 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -464,6 +464,7 @@ "keyboardShortcuts": { "title": "キーボードショートカット", "description": "cmuxのデフォルトショートカット一覧です。cmux管理のショートカットはすべて設定画面または ~/.config/cmux/cmux.json で変更でき、2段階のコードにも対応しています。", + "sequenceSeparator": "次に", "chordsTitle": "ショートカットコード", "chordsIntro": "cmux は ~/.config/cmux/cmux.json で2段階のショートカットコードを定義できます。設定ファイル全体の仕様は 設定ドキュメント を参照してください。", "chordsCallout": "ショートカットは設定画面でも編集できますが、tmux 風のプレフィックスを正確に書きたい場合、dotfiles で管理したい場合、または null、空文字列、\"none\"、\"clear\"、\"unbound\"、\"disabled\" でアクションを未割り当てにしたい場合は cmux.json が分かりやすい方法です。", diff --git a/web/messages/km.json b/web/messages/km.json index 5fc55a33cc48..87e50515e3bd 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "ផ្លូវកាត់ក្ដារចុច", "description": "ផ្លូវកាត់ក្ដារចុចទាំងអស់ដែលមានក្នុង cmux, ដាក់ជាក្រុមតាមប្រភេទ។", + "sequenceSeparator": "បន្ទាប់មក", "metaDescription": "ផ្លូវកាត់ក្ដារចុច cmux ទាំងអស់សម្រាប់ workspace, surface, បន្ទះបំបែក, កម្មវិធីរុករក, ជូនដំណឹង, ស្វែងរក, និងការគ្រប់គ្រងបង្អួចនៅលើ macOS។", "searchPlaceholder": "ស្វែងរកផ្លូវកាត់...", "searchLabel": "ស្វែងរកផ្លូវកាត់ក្ដារចុច", diff --git a/web/messages/ko.json b/web/messages/ko.json index 3975f0914ac6..d8d881d2f407 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "키보드 단축키", "description": "카테고리별로 정리된 cmux의 모든 키보드 단축키.", + "sequenceSeparator": "다음", "metaDescription": "워크스페이스, 서피스, 분할 패널, 브라우저, 알림, 검색, 창 관리를 위한 cmux의 모든 macOS 키보드 단축키.", "searchPlaceholder": "단축키 검색...", "searchLabel": "키보드 단축키 검색", diff --git a/web/messages/no.json b/web/messages/no.json index e8807e30ab87..185e08ccb6fa 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Tastatursnarveier", "description": "Alle tastatursnarveier tilgjengelige i cmux, gruppert etter kategori.", + "sequenceSeparator": "deretter", "metaDescription": "Alle cmux-tastatursnarveier for arbeidsområder, overflater, delte paneler, nettleser, varsler, søk og vindusbehandling på macOS.", "searchPlaceholder": "Søk etter snarveier...", "searchLabel": "Søk i tastatursnarveier", diff --git a/web/messages/pl.json b/web/messages/pl.json index 1a9cf948043f..1a1e7d48e2d7 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Skróty klawiszowe", "description": "Wszystkie skróty klawiszowe dostępne w cmux, pogrupowane według kategorii.", + "sequenceSeparator": "potem", "metaDescription": "Wszystkie skróty klawiszowe cmux dla workspace'ów, surface'ów, dzielonych paneli, przeglądarki, powiadomień, wyszukiwania i zarządzania oknami na macOS.", "searchPlaceholder": "Szukaj skrótów...", "searchLabel": "Szukaj skrótów klawiszowych", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 1c269873b80c..c0f9b6bd3a25 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Atalhos de Teclado", "description": "Todos os atalhos de teclado disponíveis no cmux, agrupados por categoria.", + "sequenceSeparator": "depois", "metaDescription": "Todos os atalhos de teclado do cmux para workspaces, superfícies, painéis divididos, navegador, notificações, busca e gerenciamento de janelas no macOS.", "searchPlaceholder": "Buscar atalhos...", "searchLabel": "Buscar atalhos de teclado", diff --git a/web/messages/ru.json b/web/messages/ru.json index 74674f0b239f..3a934382d778 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Горячие клавиши", "description": "Все горячие клавиши в cmux, сгруппированные по категориям.", + "sequenceSeparator": "затем", "metaDescription": "Все горячие клавиши cmux для рабочих пространств, поверхностей, разделённых панелей, браузера, уведомлений, поиска и управления окнами на macOS.", "searchPlaceholder": "Поиск по горячим клавишам...", "searchLabel": "Поиск горячих клавиш", diff --git a/web/messages/th.json b/web/messages/th.json index e4c5e4d11a4a..da1bb91d3845 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "คีย์ลัด", "description": "คีย์ลัดทั้งหมดใน cmux จัดกลุ่มตามหมวดหมู่", + "sequenceSeparator": "แล้ว", "metaDescription": "คีย์ลัด cmux ทั้งหมดสำหรับ workspace, surface, แยกหน้าจอ, เบราว์เซอร์, แจ้งเตือน, ค้นหา และจัดการหน้าต่างบน macOS", "searchPlaceholder": "ค้นหาทางลัด...", "searchLabel": "ค้นหาทางลัดแป้นพิมพ์", diff --git a/web/messages/tr.json b/web/messages/tr.json index 2c11ecb9ba2a..02e54d73e602 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Klavye Kısayolları", "description": "cmux'ta mevcut tüm klavye kısayolları, kategoriye göre gruplandırılmış.", + "sequenceSeparator": "sonra", "metaDescription": "macOS'ta çalışma alanları, yüzeyler, bölünmüş paneller, tarayıcı, bildirimler, bulma ve pencere yönetimi için tüm cmux klavye kısayolları.", "searchPlaceholder": "Kısayol ara...", "searchLabel": "Klavye kısayollarını ara", diff --git a/web/messages/uk.json b/web/messages/uk.json index 38f150f30fc7..76fd0ac62136 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "Клавіатурні скорочення", "description": "Усі клавіатурні скорочення cmux, згруповані за категоріями.", + "sequenceSeparator": "потім", "metaTitle": "Клавіатурні скорочення", "metaDescription": "Усі клавіатурні скорочення cmux для робочих просторів, поверхонь, розділених панелей, браузера, сповіщень, пошуку та керування вікнами на macOS.", "searchPlaceholder": "Пошук скорочень...", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 8c67839c8ee0..71695d55c5a1 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "快捷键", "description": "cmux 中所有可用的快捷键,按类别分组。", + "sequenceSeparator": "然后", "metaDescription": "cmux 的所有 macOS 快捷键,涵盖工作区、surface、分屏面板、浏览器、通知、查找和窗口管理。", "searchPlaceholder": "搜索快捷键...", "searchLabel": "搜索键盘快捷键", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 0ff43b9ba429..e8f5d6ae12b2 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -406,6 +406,7 @@ "keyboardShortcuts": { "title": "鍵盤快捷鍵", "description": "cmux 中所有可用的鍵盤快捷鍵,依類別分組。", + "sequenceSeparator": "然後", "metaDescription": "cmux 所有鍵盤快捷鍵,涵蓋工作區、surface、分割窗格、瀏覽器、通知、搜尋和 macOS 視窗管理。", "searchPlaceholder": "搜尋快捷鍵...", "searchLabel": "搜尋鍵盤快捷鍵", From 0851bf864452dffa8c567ce6ad4752a582f2fd15 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 4 Jun 2026 05:12:23 -0700 Subject: [PATCH 03/29] fix: expose resize split shortcuts in settings catalog --- .../Values/ShortcutAction+Defaults.swift | 211 +++++++++++------- .../CmuxSettings/Values/ShortcutAction.swift | 15 +- .../ShortcutActionTests.swift | 28 +++ .../Sections/GlobalHotkeySection.swift | 2 +- .../Sections/KeyboardShortcutsSection.swift | 31 ++- ...dShortcutSettingsEqualizeSplitsTests.swift | 16 ++ 6 files changed, 202 insertions(+), 101 deletions(-) create mode 100644 Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index effb2f8e1940..bcf85deb9507 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -1,97 +1,136 @@ import Foundation extension ShortcutAction { - /// The factory-default ``ShortcutStroke`` for this action. + /// The factory-default shortcut for this action. /// /// Mirrors the table in /// `Sources/KeyboardShortcutSettings.swift` so the package's - /// settings UI can show "(default: ⌘N)" instead of "(default)" - /// next to unbound rows, and so the Reset action in the Settings - /// UI can restore a row by writing the default stroke through - /// the JSON store. - public var defaultStroke: ShortcutStroke? { + /// settings UI can show "(default: ⌘N)" or a chorded default next + /// to unbound rows, and so reset/conflict logic sees the same + /// effective shortcut as app routing. + public var defaultShortcut: StoredShortcut? { switch self { - case .openSettings: return ShortcutStroke(key: ",", command: true) - case .reloadConfiguration: return ShortcutStroke(key: ",", command: true, shift: true) - case .showHideAllWindows: return ShortcutStroke(key: ".", command: true, option: true, control: true) - case .globalSearch: return ShortcutStroke(key: "f", command: true, option: true) - case .newWindow: return ShortcutStroke(key: "n", command: true, shift: true) - case .closeWindow: return ShortcutStroke(key: "w", command: true, control: true) - case .toggleFullScreen: return ShortcutStroke(key: "f", command: true, control: true) - case .quit: return ShortcutStroke(key: "q", command: true) - case .toggleSidebar: return ShortcutStroke(key: "b", command: true) - case .newTab: return ShortcutStroke(key: "n", command: true) - case .openFolder: return ShortcutStroke(key: "o", command: true) - case .reopenPreviousSession: return ShortcutStroke(key: "o", command: true, shift: true) - case .goToWorkspace: return ShortcutStroke(key: "p", command: true) - case .commandPalette: return ShortcutStroke(key: "p", command: true, shift: true) - case .commandPaletteNext: return ShortcutStroke(key: "n", control: true) - case .commandPalettePrevious: return ShortcutStroke(key: "p", control: true) + case .openSettings: return Self.shortcut(key: ",", command: true) + case .reloadConfiguration: return Self.shortcut(key: ",", command: true, shift: true) + case .showHideAllWindows: return Self.shortcut(key: ".", command: true, option: true, control: true) + case .globalSearch: return Self.shortcut(key: "f", command: true, option: true) + case .newWindow: return Self.shortcut(key: "n", command: true, shift: true) + case .closeWindow: return Self.shortcut(key: "w", command: true, control: true) + case .toggleFullScreen: return Self.shortcut(key: "f", command: true, control: true) + case .quit: return Self.shortcut(key: "q", command: true) + case .toggleSidebar: return Self.shortcut(key: "b", command: true) + case .newTab: return Self.shortcut(key: "n", command: true) + case .openFolder: return Self.shortcut(key: "o", command: true) + case .reopenPreviousSession: return Self.shortcut(key: "o", command: true, shift: true) + case .goToWorkspace: return Self.shortcut(key: "p", command: true) + case .commandPalette: return Self.shortcut(key: "p", command: true, shift: true) + case .commandPaletteNext: return Self.shortcut(key: "n", control: true) + case .commandPalettePrevious: return Self.shortcut(key: "p", control: true) case .sendFeedback: return nil - case .showNotifications: return ShortcutStroke(key: "i", command: true) - case .jumpToUnread: return ShortcutStroke(key: "u", command: true, shift: true) - case .toggleUnread: return ShortcutStroke(key: "u", command: true, option: true) - case .markOldestUnreadAndJumpNext: return ShortcutStroke(key: "u", command: true, control: true) - case .focusRightSidebar: return ShortcutStroke(key: "e", command: true, shift: true) - case .switchRightSidebarToFiles: return ShortcutStroke(key: "1", control: true) - case .switchRightSidebarToFind: return ShortcutStroke(key: "2", control: true) - case .switchRightSidebarToSessions: return ShortcutStroke(key: "3", control: true) - case .switchRightSidebarToFeed: return ShortcutStroke(key: "4", control: true) - case .switchRightSidebarToDock: return ShortcutStroke(key: "5", control: true) - case .triggerFlash: return ShortcutStroke(key: "h", command: true, shift: true) - case .nextSidebarTab: return ShortcutStroke(key: "]", command: true, control: true) - case .prevSidebarTab: return ShortcutStroke(key: "[", command: true, control: true) - case .focusHistoryBack: return ShortcutStroke(key: "[", command: true) - case .focusHistoryForward: return ShortcutStroke(key: "]", command: true) - case .renameTab: return ShortcutStroke(key: "r", command: true) - case .renameWorkspace: return ShortcutStroke(key: "r", command: true, shift: true) - case .editWorkspaceDescription: return ShortcutStroke(key: "e", command: true, option: true) - case .closeTab: return ShortcutStroke(key: "w", command: true) - case .closeOtherTabsInPane: return ShortcutStroke(key: "t", command: true, option: true) - case .closeWorkspace: return ShortcutStroke(key: "w", command: true, shift: true) - case .reopenClosedBrowserPanel: return ShortcutStroke(key: "t", command: true, shift: true) - case .focusLeft: return ShortcutStroke(key: "←", command: true, option: true) - 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 .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) - case .equalizeSplits: return ShortcutStroke(key: "=", command: true, control: true) - case .splitBrowserRight: return ShortcutStroke(key: "d", command: true, option: true) - case .splitBrowserDown: return ShortcutStroke(key: "d", command: true, shift: true, option: true) - case .nextSurface: return ShortcutStroke(key: "]", command: true, shift: true) - case .prevSurface: return ShortcutStroke(key: "[", command: true, shift: true) - case .selectSurfaceByNumber: return ShortcutStroke(key: "1", control: true) - case .selectWorkspaceByNumber: return ShortcutStroke(key: "1", command: true) - case .newSurface: return ShortcutStroke(key: "t", command: true) - case .toggleTerminalCopyMode: return ShortcutStroke(key: "m", command: true, shift: true) - case .focusTextBoxInput: return ShortcutStroke(key: "a", command: true, shift: true) - case .attachTextBoxFile: return ShortcutStroke(key: "a", command: true, shift: true, option: true) - case .toggleRightSidebar: return ShortcutStroke(key: "b", command: true, option: true) - case .openDiffViewer: return ShortcutStroke(key: "d", command: true, shift: true, control: true) - case .saveFilePreview: return ShortcutStroke(key: "s", command: true) - case .openBrowser: return ShortcutStroke(key: "l", command: true, shift: true) - case .focusBrowserAddressBar: return ShortcutStroke(key: "l", command: true) - case .browserBack: return ShortcutStroke(key: "[", command: true) - case .browserForward: return ShortcutStroke(key: "]", command: true) - case .browserReload: return ShortcutStroke(key: "r", command: true) - case .browserZoomIn: return ShortcutStroke(key: "=", command: true) - case .browserZoomOut: return ShortcutStroke(key: "-", command: true) - case .browserZoomReset: return ShortcutStroke(key: "0", command: true) - case .markdownZoomIn: return ShortcutStroke(key: "=", command: true) - case .markdownZoomOut: return ShortcutStroke(key: "-", command: true) - case .markdownZoomReset: return ShortcutStroke(key: "0", command: true) - case .find: return ShortcutStroke(key: "f", command: true) - case .findInDirectory: return ShortcutStroke(key: "f", command: true, shift: true) - case .findNext: return ShortcutStroke(key: "g", command: true) - case .findPrevious: return ShortcutStroke(key: "g", command: true, option: true) - case .hideFind: return ShortcutStroke(key: "f", command: true, shift: true, option: true) - case .useSelectionForFind: return ShortcutStroke(key: "e", command: true) - case .toggleBrowserDeveloperTools: return ShortcutStroke(key: "i", command: true, option: true) - case .showBrowserJavaScriptConsole: return ShortcutStroke(key: "c", command: true, option: true) - case .toggleBrowserFocusMode: return ShortcutStroke(key: "\r", command: true, option: true) - case .toggleReactGrab: return ShortcutStroke(key: "g", command: true, shift: true) + case .showNotifications: return Self.shortcut(key: "i", command: true) + case .jumpToUnread: return Self.shortcut(key: "u", command: true, shift: true) + case .toggleUnread: return Self.shortcut(key: "u", command: true, option: true) + case .markOldestUnreadAndJumpNext: return Self.shortcut(key: "u", command: true, control: true) + case .focusRightSidebar: return Self.shortcut(key: "e", command: true, shift: true) + case .switchRightSidebarToFiles: return Self.shortcut(key: "1", control: true) + case .switchRightSidebarToFind: return Self.shortcut(key: "2", control: true) + case .switchRightSidebarToSessions: return Self.shortcut(key: "3", control: true) + case .switchRightSidebarToFeed: return Self.shortcut(key: "4", control: true) + case .switchRightSidebarToDock: return Self.shortcut(key: "5", control: true) + case .triggerFlash: return Self.shortcut(key: "h", command: true, shift: true) + case .nextSidebarTab: return Self.shortcut(key: "]", command: true, control: true) + case .prevSidebarTab: return Self.shortcut(key: "[", command: true, control: true) + case .focusHistoryBack: return Self.shortcut(key: "[", command: true) + case .focusHistoryForward: return Self.shortcut(key: "]", command: true) + case .renameTab: return Self.shortcut(key: "r", command: true) + case .renameWorkspace: return Self.shortcut(key: "r", command: true, shift: true) + case .editWorkspaceDescription: return Self.shortcut(key: "e", command: true, option: true) + case .closeTab: return Self.shortcut(key: "w", command: true) + case .closeOtherTabsInPane: return Self.shortcut(key: "t", command: true, option: true) + case .closeWorkspace: return Self.shortcut(key: "w", command: true, shift: true) + case .reopenClosedBrowserPanel: return Self.shortcut(key: "t", command: true, shift: true) + case .focusLeft: return Self.shortcut(key: "←", command: true, option: true) + case .focusRight: return Self.shortcut(key: "→", command: true, option: true) + case .focusUp: return Self.shortcut(key: "↑", command: true, option: true) + case .focusDown: return Self.shortcut(key: "↓", command: true, option: true) + case .resizeSplitLeft: return Self.tmuxSplitResizeShortcut(chordKey: "←") + case .resizeSplitRight: return Self.tmuxSplitResizeShortcut(chordKey: "→") + case .resizeSplitUp: return Self.tmuxSplitResizeShortcut(chordKey: "↑") + case .resizeSplitDown: return Self.tmuxSplitResizeShortcut(chordKey: "↓") + case .splitRight: return Self.shortcut(key: "d", command: true) + case .splitDown: return Self.shortcut(key: "d", command: true, shift: true) + case .toggleSplitZoom: return Self.shortcut(key: "\r", command: true, shift: true) + case .equalizeSplits: return Self.shortcut(key: "=", command: true, control: true) + case .splitBrowserRight: return Self.shortcut(key: "d", command: true, option: true) + case .splitBrowserDown: return Self.shortcut(key: "d", command: true, shift: true, option: true) + case .nextSurface: return Self.shortcut(key: "]", command: true, shift: true) + case .prevSurface: return Self.shortcut(key: "[", command: true, shift: true) + case .selectSurfaceByNumber: return Self.shortcut(key: "1", control: true) + case .selectWorkspaceByNumber: return Self.shortcut(key: "1", command: true) + case .newSurface: return Self.shortcut(key: "t", command: true) + case .toggleTerminalCopyMode: return Self.shortcut(key: "m", command: true, shift: true) + case .focusTextBoxInput: return Self.shortcut(key: "a", command: true, shift: true) + case .attachTextBoxFile: return Self.shortcut(key: "a", command: true, shift: true, option: true) + case .toggleRightSidebar: return Self.shortcut(key: "b", command: true, option: true) + case .openDiffViewer: return Self.shortcut(key: "d", command: true, shift: true, control: true) + case .saveFilePreview: return Self.shortcut(key: "s", command: true) + case .openBrowser: return Self.shortcut(key: "l", command: true, shift: true) + case .focusBrowserAddressBar: return Self.shortcut(key: "l", command: true) + case .browserBack: return Self.shortcut(key: "[", command: true) + case .browserForward: return Self.shortcut(key: "]", command: true) + case .browserReload: return Self.shortcut(key: "r", command: true) + case .browserZoomIn: return Self.shortcut(key: "=", command: true) + case .browserZoomOut: return Self.shortcut(key: "-", command: true) + case .browserZoomReset: return Self.shortcut(key: "0", command: true) + case .markdownZoomIn: return Self.shortcut(key: "=", command: true) + case .markdownZoomOut: return Self.shortcut(key: "-", command: true) + case .markdownZoomReset: return Self.shortcut(key: "0", command: true) + case .find: return Self.shortcut(key: "f", command: true) + case .findInDirectory: return Self.shortcut(key: "f", command: true, shift: true) + case .findNext: return Self.shortcut(key: "g", command: true) + case .findPrevious: return Self.shortcut(key: "g", command: true, option: true) + case .hideFind: return Self.shortcut(key: "f", command: true, shift: true, option: true) + case .useSelectionForFind: return Self.shortcut(key: "e", command: true) + case .toggleBrowserDeveloperTools: return Self.shortcut(key: "i", command: true, option: true) + case .showBrowserJavaScriptConsole: return Self.shortcut(key: "c", command: true, option: true) + case .toggleBrowserFocusMode: return Self.shortcut(key: "\r", command: true, option: true) + case .toggleReactGrab: return Self.shortcut(key: "g", command: true, shift: true) + } + } + + /// The factory-default first stroke for single-stroke shortcuts. + /// + /// Chorded defaults return `nil`; use ``defaultShortcut`` when + /// callers need to preserve the whole binding. + public var defaultStroke: ShortcutStroke? { + guard let shortcut = defaultShortcut, shortcut.second == nil else { + return nil } + return shortcut.first + } + + private static func shortcut( + key: String, + command: Bool = false, + shift: Bool = false, + option: Bool = false, + control: Bool = false + ) -> StoredShortcut { + StoredShortcut( + first: ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + ) + } + + private static func tmuxSplitResizeShortcut(chordKey: String) -> StoredShortcut { + StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: chordKey, option: true) + ) } } diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 29a3ddf8e62a..5a45c1e85324 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -69,6 +69,10 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case focusRight case focusUp case focusDown + case resizeSplitLeft + case resizeSplitRight + case resizeSplitUp + case resizeSplitDown case splitRight case splitDown case toggleSplitZoom @@ -143,9 +147,10 @@ extension ShortcutAction { .reopenClosedBrowserPanel, .newSurface, .toggleTerminalCopyMode, .focusTextBoxInput, .attachTextBoxFile: return .navigation - case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown, - .toggleSplitZoom, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, - .toggleRightSidebar: + case .focusLeft, .focusRight, .focusUp, .focusDown, + .resizeSplitLeft, .resizeSplitRight, .resizeSplitUp, .resizeSplitDown, + .splitRight, .splitDown, .toggleSplitZoom, .equalizeSplits, + .splitBrowserRight, .splitBrowserDown, .toggleRightSidebar: return .panes case .openDiffViewer, .saveFilePreview, .openBrowser, .focusBrowserAddressBar, .browserBack, .browserForward, .browserReload, .browserZoomIn, .browserZoomOut, @@ -211,6 +216,10 @@ extension ShortcutAction { case .focusRight: return "Focus Pane Right" case .focusUp: return "Focus Pane Up" case .focusDown: return "Focus Pane Down" + case .resizeSplitLeft: return String(localized: "shortcut.resizeSplitLeft.label", defaultValue: "Resize Split Left") + case .resizeSplitRight: return String(localized: "shortcut.resizeSplitRight.label", defaultValue: "Resize Split Right") + case .resizeSplitUp: return String(localized: "shortcut.resizeSplitUp.label", defaultValue: "Resize Split Up") + case .resizeSplitDown: return String(localized: "shortcut.resizeSplitDown.label", defaultValue: "Resize Split Down") case .splitRight: return "Split Right" case .splitDown: return "Split Down" case .toggleSplitZoom: return "Toggle Pane Zoom" diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift new file mode 100644 index 000000000000..cd5624975038 --- /dev/null +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import CmuxSettings + +@Suite("ShortcutAction") +struct ShortcutActionTests { + @Test func resizeSplitActionsExposePaneGroupNamesAndChordDefaults() { + let expected: [(ShortcutAction, String, String)] = [ + (.resizeSplitLeft, "Resize Split Left", "←"), + (.resizeSplitRight, "Resize Split Right", "→"), + (.resizeSplitUp, "Resize Split Up", "↑"), + (.resizeSplitDown, "Resize Split Down", "↓"), + ] + + for (action, displayName, chordKey) in expected { + #expect(ShortcutAction.allCases.contains(action)) + #expect(action.group == .panes) + #expect(action.displayName == displayName) + #expect(action.defaultStroke == nil) + #expect( + action.defaultShortcut == StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: chordKey, option: true) + ) + ) + } + } +} diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift index 35d57a779ddc..787975aacaa1 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -128,7 +128,7 @@ public struct GlobalHotkeySection: View { private var currentShortcut: StoredShortcut? { if let override = bindings[hotkeyAction.rawValue] { return override } - return hotkeyAction.defaultStroke.map { StoredShortcut(first: $0) } + return hotkeyAction.defaultShortcut } private func placeholderText(for shortcut: StoredShortcut?) -> String { diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index 650082d4db1c..e21f90ebf993 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -134,7 +134,7 @@ public struct KeyboardShortcutsSection: View { @ViewBuilder private func actionRow(_ action: ShortcutAction) -> some View { let override = bindings[action.rawValue] - let effective = override ?? action.defaultStroke.map { StoredShortcut(first: $0) } + let effective = override ?? action.defaultShortcut let isUnbound = effective?.isUnbound ?? true let canRestore = isUnbound && restoreShortcuts[action.rawValue] != nil let bareKeyRejected = bareKeyRejections.contains(action.rawValue) @@ -157,7 +157,7 @@ public struct KeyboardShortcutsSection: View { // displayed shortcut string in parentheses so the user can // identify which existing shortcut is in the way. let conflictOverride = bindings[conflict.rawValue] - let conflictEffective = conflictOverride ?? conflict.defaultStroke.map { StoredShortcut(first: $0) } + let conflictEffective = conflictOverride ?? conflict.defaultShortcut let conflictShortcutString = conflictEffective.map { format($0) } ?? "" let messageFormat = String( localized: "shortcut.recorder.error.conflictsWithAction", @@ -291,12 +291,9 @@ public struct KeyboardShortcutsSection: View { // MARK: - Conflict helpers /// Mirrors legacy `KeyboardShortcutSettings.Action.conflicts(with:proposedAction:configuredShortcut:)` - /// at a coarser grain: only treat two actions as conflicting when the - /// *configured* (effective) shortcut of the other action is not - /// unbound and shares the same first stroke. The legacy implementation - /// has per-action overrides (e.g. number-stack actions allow sharing), - /// but we don't have that catalog data here, so the conservative - /// "same first stroke && both bound" check is the best we can do. + /// at the shortcut-shape level: single-stroke bindings conflict with + /// any binding that needs the same first stroke, while two chords can + /// share a prefix when their second strokes differ. private func formatPlaceholder(effective: StoredShortcut?) -> String { let unboundLabel = String(localized: "shortcut.unbound.displayValue", defaultValue: "None") guard let effective else { return unboundLabel } @@ -307,13 +304,25 @@ public struct KeyboardShortcutsSection: View { private func detectConflict(for action: ShortcutAction, stroke: StoredShortcut) -> ShortcutAction? { for other in ShortcutAction.allCases where other != action { let override = bindings[other.rawValue] - let effective = override ?? other.defaultStroke.map { StoredShortcut(first: $0) } + let effective = override ?? other.defaultShortcut guard let effective, !effective.isUnbound else { continue } - if stroke.first == effective.first { return other } + if shortcutsConflict(stroke, effective) { return other } } return nil } + private func shortcutsConflict(_ lhs: StoredShortcut, _ rhs: StoredShortcut) -> Bool { + guard !lhs.isUnbound, !rhs.isUnbound, lhs.first == rhs.first else { + return false + } + switch (lhs.second, rhs.second) { + case (.none, _), (_, .none): + return true + case let (.some(lhsSecond), .some(rhsSecond)): + return lhsSecond == rhsSecond + } + } + /// Mirrors legacy `StoredShortcut.displayString`: returns localized /// "None" for unbound, formats the first stroke (and optional chord /// second stroke) via ``Self/strokeDisplayString(_:)`` so named keys @@ -411,7 +420,7 @@ public struct KeyboardShortcutsSection: View { conflictRejections.removeValue(forKey: key) continue } - let effective = bindings[action.rawValue] ?? action.defaultStroke.map { StoredShortcut(first: $0) } + let effective = bindings[action.rawValue] ?? action.defaultShortcut if let effective, detectConflict(for: action, stroke: effective) == nil { conflictRejections.removeValue(forKey: key) } else if effective == nil { diff --git a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift index 52fb76e28129..c4b686f05515 100644 --- a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift @@ -1,4 +1,5 @@ import XCTest +import enum CmuxSettings.ShortcutAction #if canImport(cmux_DEV) @testable import cmux_DEV @@ -7,6 +8,21 @@ import XCTest #endif final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { + func testSettingsPackageCatalogIncludesResizeSplitShortcutActions() { + let expected: [(KeyboardShortcutSettings.Action, ShortcutAction)] = [ + (.resizeSplitLeft, .resizeSplitLeft), + (.resizeSplitRight, .resizeSplitRight), + (.resizeSplitUp, .resizeSplitUp), + (.resizeSplitDown, .resizeSplitDown), + ] + + let packageActions = Set(ShortcutAction.allCases) + for (appAction, packageAction) in expected { + XCTAssertTrue(packageActions.contains(packageAction)) + XCTAssertEqual(packageAction.rawValue, appAction.rawValue) + } + } + func testResizeSplitDefaultsUseTmuxStyleArrowChords() { let expected: [(KeyboardShortcutSettings.Action, String)] = [ (.resizeSplitLeft, "←"), From bf40fe65d317cf4c6e6cbe11a5d96520515ed8c4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 4 Jun 2026 05:20:22 -0700 Subject: [PATCH 04/29] fix: snapshot keyboard shortcut settings rows --- .../Sections/KeyboardShortcutsSection.swift | 177 ++++++++++++------ Resources/Localizable.xcstrings | 24 +++ 2 files changed, 145 insertions(+), 56 deletions(-) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index e21f90ebf993..d068fa57ada4 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -65,7 +65,11 @@ public struct KeyboardShortcutsSection: View { let actions = Self.settingsVisibleActions LazyVStack(alignment: .leading, spacing: 0) { ForEach(Array(actions.enumerated()), id: \.element) { index, action in - actionRow(action) + let snapshot = shortcutRowSnapshot(for: action) + actionRow( + snapshot: snapshot, + actions: shortcutRowActions(for: snapshot) + ) if index < actions.count - 1 { SettingsCardDivider() } @@ -132,41 +136,12 @@ public struct KeyboardShortcutsSection: View { } @ViewBuilder - private func actionRow(_ action: ShortcutAction) -> some View { - let override = bindings[action.rawValue] - let effective = override ?? action.defaultShortcut - let isUnbound = effective?.isUnbound ?? true - let canRestore = isUnbound && restoreShortcuts[action.rawValue] != nil - let bareKeyRejected = bareKeyRejections.contains(action.rawValue) - // Drive the validation banner off the explicit rejection state, - // not off live bindings. Legacy never persists a conflicting - // shortcut: `ShortcutRecorderSettingsControl` captures the - // rejected attempt and shows the banner without ever writing - // the bad value, so Undo simply clears `rejectedAttempt`. - let conflict = conflictRejections[action.rawValue] - let validationMessage: String? = { - if bareKeyRejected { - return String( - localized: "shortcut.recorder.error.bareKeyNotAllowed", - defaultValue: "Shortcuts must include ⌘ ⌥ ⌃ or ⇧" - ) - } - if let conflict { - // Mirror legacy `ShortcutRecorderValidationPresentation.message` - // wording: include both the conflicting action label AND its - // displayed shortcut string in parentheses so the user can - // identify which existing shortcut is in the way. - let conflictOverride = bindings[conflict.rawValue] - let conflictEffective = conflictOverride ?? conflict.defaultShortcut - let conflictShortcutString = conflictEffective.map { format($0) } ?? "" - let messageFormat = String( - localized: "shortcut.recorder.error.conflictsWithAction", - defaultValue: "This shortcut conflicts with %@ (%@)." - ) - return String.localizedStringWithFormat(messageFormat, conflict.displayName, conflictShortcutString) - } - return nil - }() + private func actionRow( + snapshot: ShortcutActionRowSnapshot, + actions: ShortcutActionRowActions + ) -> some View { + let action = snapshot.action + let validationMessage = validationMessage(for: snapshot) let subtitle: String? = nil VStack(alignment: .leading, spacing: 4) { @@ -183,37 +158,30 @@ public struct KeyboardShortcutsSection: View { Spacer() ShortcutRecorderView( - placeholder: formatPlaceholder(effective: effective), - chordsEnabled: chordModeActions.contains(action.rawValue), - hasPendingRejection: bareKeyRejected, - onStroke: { stroke in Task { await assign(stroke: stroke, to: action) } }, - onChord: { chord in Task { await assignChord(chord, to: action) } }, - onBareKeyRejected: { bareKeyRejections.insert(action.rawValue) } + placeholder: formatPlaceholder(effective: snapshot.effective), + chordsEnabled: snapshot.chordsEnabled, + hasPendingRejection: snapshot.bareKeyRejected, + onStroke: actions.onStroke, + onChord: actions.onChord, + onBareKeyRejected: actions.onBareKeyRejected ) .frame(width: 160) Button { - bareKeyRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - if canRestore, let restore = restoreShortcuts[action.rawValue] { - Task { await restoreBinding(restore, for: action) } - } else if let effective, !effective.isUnbound { - restoreShortcuts[action.rawValue] = effective - Task { await clearBinding(for: action) } - } + actions.onClearOrRestore() } label: { - Image(systemName: canRestore ? "arrow.counterclockwise.circle.fill" : "xmark.circle.fill") + Image(systemName: snapshot.canRestore ? "arrow.counterclockwise.circle.fill" : "xmark.circle.fill") .imageScale(.medium) } .buttonStyle(.borderless) - .disabled(isUnbound && !canRestore) + .disabled(snapshot.isUnbound && !snapshot.canRestore) .help( - canRestore + snapshot.canRestore ? String(localized: "shortcut.recorder.restore.help", defaultValue: "Restore previous shortcut") : String(localized: "shortcut.recorder.clear.help", defaultValue: "Unbind shortcut") ) .accessibilityLabel( - canRestore + snapshot.canRestore ? String(localized: "shortcut.recorder.restore", defaultValue: "Restore") : String(localized: "shortcut.recorder.clear", defaultValue: "Unbind") ) @@ -238,8 +206,7 @@ public struct KeyboardShortcutsSection: View { // that so users can dismiss the conflict banner without // having to record a different shortcut. Button(String(localized: "shortcut.recorder.undo", defaultValue: "Undo")) { - bareKeyRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) + actions.onDismissRejection() } .buttonStyle(.link) .font(.caption) @@ -290,6 +257,104 @@ public struct KeyboardShortcutsSection: View { // MARK: - Conflict helpers + private struct ShortcutActionRowSnapshot { + let action: ShortcutAction + let effective: StoredShortcut? + let restoreShortcut: StoredShortcut? + let isUnbound: Bool + let canRestore: Bool + let bareKeyRejected: Bool + let conflict: ShortcutAction? + let conflictShortcutString: String? + let chordsEnabled: Bool + } + + private struct ShortcutActionRowActions { + let onStroke: (ShortcutStroke) -> Void + let onChord: (StoredShortcut) -> Void + let onBareKeyRejected: () -> Void + let onClearOrRestore: () -> Void + let onDismissRejection: () -> Void + } + + private func shortcutRowSnapshot(for action: ShortcutAction) -> ShortcutActionRowSnapshot { + let override = bindings[action.rawValue] + let effective = override ?? action.defaultShortcut + let restoreShortcut = restoreShortcuts[action.rawValue] + let conflict = conflictRejections[action.rawValue] + let conflictShortcutString = conflict.flatMap { conflictAction -> String? in + let conflictOverride = bindings[conflictAction.rawValue] + let conflictEffective = conflictOverride ?? conflictAction.defaultShortcut + return conflictEffective.map { format($0) } + } + let isUnbound = effective?.isUnbound ?? true + + return ShortcutActionRowSnapshot( + action: action, + effective: effective, + restoreShortcut: restoreShortcut, + isUnbound: isUnbound, + canRestore: isUnbound && restoreShortcut != nil, + bareKeyRejected: bareKeyRejections.contains(action.rawValue), + conflict: conflict, + conflictShortcutString: conflictShortcutString, + chordsEnabled: chordModeActions.contains(action.rawValue) + ) + } + + private func shortcutRowActions(for snapshot: ShortcutActionRowSnapshot) -> ShortcutActionRowActions { + ShortcutActionRowActions( + onStroke: { stroke in + Task { await assign(stroke: stroke, to: snapshot.action) } + }, + onChord: { chord in + Task { await assignChord(chord, to: snapshot.action) } + }, + onBareKeyRejected: { + bareKeyRejections.insert(snapshot.action.rawValue) + }, + onClearOrRestore: { + bareKeyRejections.remove(snapshot.action.rawValue) + conflictRejections.removeValue(forKey: snapshot.action.rawValue) + if snapshot.canRestore, let restore = snapshot.restoreShortcut { + Task { await restoreBinding(restore, for: snapshot.action) } + } else if let effective = snapshot.effective, !effective.isUnbound { + restoreShortcuts[snapshot.action.rawValue] = effective + Task { await clearBinding(for: snapshot.action) } + } + }, + onDismissRejection: { + bareKeyRejections.remove(snapshot.action.rawValue) + conflictRejections.removeValue(forKey: snapshot.action.rawValue) + } + ) + } + + private func validationMessage(for snapshot: ShortcutActionRowSnapshot) -> String? { + if snapshot.bareKeyRejected { + return String( + localized: "shortcut.recorder.error.bareKeyNotAllowed", + defaultValue: "Shortcuts must include ⌘ ⌥ ⌃ or ⇧" + ) + } + if let conflict = snapshot.conflict { + // Mirror legacy `ShortcutRecorderValidationPresentation.message` + // wording: include both the conflicting action label AND its + // displayed shortcut string in parentheses so the user can + // identify which existing shortcut is in the way. + let messageFormat = String( + localized: "shortcut.recorder.error.conflictsWithAction", + defaultValue: "This shortcut conflicts with %@ (%@)." + ) + return String.localizedStringWithFormat( + messageFormat, + conflict.displayName, + snapshot.conflictShortcutString ?? "" + ) + } + return nil + } + /// Mirrors legacy `KeyboardShortcutSettings.Action.conflicts(with:proposedAction:configuredShortcut:)` /// at the shortcut-shape level: single-stroke bindings conflict with /// any binding that needs the same first stroke, while two chords can diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index acd52b68d5cf..90a317807444 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -125716,6 +125716,12 @@ "value": "분할을 아래로 크기 조절" } }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្ដូរទំហំការបំបែកចុះក្រោម" + } + }, "nb": { "stringUnit": { "state": "translated", @@ -125835,6 +125841,12 @@ "value": "분할을 왼쪽으로 크기 조절" } }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្ដូរទំហំការបំបែកទៅឆ្វេង" + } + }, "nb": { "stringUnit": { "state": "translated", @@ -125954,6 +125966,12 @@ "value": "분할을 오른쪽으로 크기 조절" } }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្ដូរទំហំការបំបែកទៅស្តាំ" + } + }, "nb": { "stringUnit": { "state": "translated", @@ -126073,6 +126091,12 @@ "value": "분할을 위로 크기 조절" } }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្ដូរទំហំការបំបែកឡើងលើ" + } + }, "nb": { "stringUnit": { "state": "translated", From 04575e7fe602292df337198ab93e02b68c85f253 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 4 Jun 2026 05:25:54 -0700 Subject: [PATCH 05/29] fix: allow chord recording in shortcut settings --- .../Sections/KeyboardShortcutsSection.swift | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index d068fa57ada4..aff15dd657cd 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -14,7 +14,7 @@ public struct KeyboardShortcutsSection: View { @State private var bindings: [String: StoredShortcut] = [:] @State private var streamTask: Task? - @State private var chordModeActions: Set = [] + @State private var chordModeOverrides: [String: Bool] = [:] @State private var restoreShortcuts: [String: StoredShortcut] = [:] @State private var bareKeyRejections: Set = [] /// Per-action "rejected attempt" snapshot used to drive the red @@ -167,6 +167,18 @@ public struct KeyboardShortcutsSection: View { ) .frame(width: 160) + Button { + actions.onToggleChordMode() + } label: { + Image(systemName: "keyboard") + .imageScale(.medium) + .foregroundStyle(snapshot.chordsEnabled ? Color.accentColor : Color.secondary) + } + .buttonStyle(.borderless) + .help(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityLabel(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityIdentifier("ShortcutRecorderChordModeButton") + Button { actions.onClearOrRestore() } label: { @@ -273,6 +285,7 @@ public struct KeyboardShortcutsSection: View { let onStroke: (ShortcutStroke) -> Void let onChord: (StoredShortcut) -> Void let onBareKeyRejected: () -> Void + let onToggleChordMode: () -> Void let onClearOrRestore: () -> Void let onDismissRejection: () -> Void } @@ -288,6 +301,7 @@ public struct KeyboardShortcutsSection: View { return conflictEffective.map { format($0) } } let isUnbound = effective?.isUnbound ?? true + let chordsEnabled = chordModeOverrides[action.rawValue] ?? (effective?.second != nil) return ShortcutActionRowSnapshot( action: action, @@ -298,7 +312,7 @@ public struct KeyboardShortcutsSection: View { bareKeyRejected: bareKeyRejections.contains(action.rawValue), conflict: conflict, conflictShortcutString: conflictShortcutString, - chordsEnabled: chordModeActions.contains(action.rawValue) + chordsEnabled: chordsEnabled ) } @@ -313,6 +327,9 @@ public struct KeyboardShortcutsSection: View { onBareKeyRejected: { bareKeyRejections.insert(snapshot.action.rawValue) }, + onToggleChordMode: { + chordModeOverrides[snapshot.action.rawValue] = !snapshot.chordsEnabled + }, onClearOrRestore: { bareKeyRejections.remove(snapshot.action.rawValue) conflictRejections.removeValue(forKey: snapshot.action.rawValue) @@ -515,6 +532,7 @@ public struct KeyboardShortcutsSection: View { // can drive the user back to a usable state. conflictRejections[action.rawValue] = conflict bareKeyRejections.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } var updated = bindings @@ -522,28 +540,30 @@ public struct KeyboardShortcutsSection: View { restoreShortcuts.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) conflictRejections.removeValue(forKey: action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated) } private func assignChord(_ chord: StoredShortcut, to action: ShortcutAction) async { if let conflict = detectConflict(for: action, stroke: chord) { conflictRejections[action.rawValue] = conflict - chordModeActions.remove(action.rawValue) bareKeyRejections.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } var updated = bindings updated[action.rawValue] = chord - chordModeActions.remove(action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) conflictRejections.removeValue(forKey: action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated) } private func clearBinding(for action: ShortcutAction) async { var updated = bindings updated[action.rawValue] = StoredShortcut.unbound + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated) } @@ -552,6 +572,7 @@ public struct KeyboardShortcutsSection: View { updated[action.rawValue] = shortcut restoreShortcuts.removeValue(forKey: action.rawValue) conflictRejections.removeValue(forKey: action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated) } @@ -560,6 +581,7 @@ public struct KeyboardShortcutsSection: View { updated.removeValue(forKey: action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) conflictRejections.removeValue(forKey: action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated) } @@ -567,6 +589,7 @@ public struct KeyboardShortcutsSection: View { restoreShortcuts.removeAll() bareKeyRejections.removeAll() conflictRejections.removeAll() + chordModeOverrides.removeAll() await write([:]) } From 1ea44077a9d6e317b311746949c7b43a9822a22d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 13:02:25 -0700 Subject: [PATCH 06/29] chore: refresh Swift file length budget --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index f79faa17b917..d3f82f51f3ba 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33857 CLI/cmux.swift 18027 Sources/AppDelegate.swift -16740 Sources/ContentView.swift +16709 Sources/ContentView.swift 14612 Sources/TerminalController.swift 13595 Sources/Panels/BrowserPanel.swift 12088 Sources/GhosttyTerminalView.swift @@ -13,8 +13,8 @@ 7911 Sources/Panels/BrowserPanelView.swift 7350 cmuxTests/WorkspaceUnitTests.swift 6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +6363 cmuxTests/GhosttyConfigTests.swift 6317 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift 6092 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift From ee030a38f76b240165c02e7608e08bed45290da9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 13:55:25 -0700 Subject: [PATCH 07/29] fix: serialize remote session process tests --- .../RemotePlatformProbeScriptTests.swift | 9 +++++---- .../RemoteSessionProcessRunnerTests.swift | 14 +++++--------- .../RemoteSessionProcessTestIsolation.swift | 7 +++++++ 3 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessTestIsolation.swift diff --git a/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePlatformProbeScriptTests.swift b/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePlatformProbeScriptTests.swift index 951ed3eadbf5..62cc7dd7c9a4 100644 --- a/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePlatformProbeScriptTests.swift +++ b/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePlatformProbeScriptTests.swift @@ -9,10 +9,10 @@ import Testing // interpolated into remote shell, and must strip internal markers from the // stdout used in user-facing error detail. // -// `.serialized`: each script case spawns a real `Process` with `Pipe`s; the -// suite shares the process-global fd table and is not parallel-safe, matching -// RemoteSessionProcessRunnerTests. -@Suite("RemotePlatformProbeScript", .serialized) +// Nested under `RemoteSessionProcessTestIsolation` so probe script pipe captures +// do not overlap with the fd-closing process-runner regression suite. +extension RemoteSessionProcessTestIsolation { +@Suite("RemotePlatformProbeScript") struct RemotePlatformProbeScriptTests { private struct ProcessResult { let status: Int32 @@ -212,3 +212,4 @@ struct RemotePlatformProbeScriptTests { return ProcessResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) } } +} diff --git a/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessRunnerTests.swift b/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessRunnerTests.swift index 640a3c5ef8b0..847dfebaca24 100644 --- a/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessRunnerTests.swift +++ b/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessRunnerTests.swift @@ -8,15 +8,10 @@ import Testing // unchanged); the launch-failure and timeout cases pin the legacy // `cmux.remote.process` error codes 1 and 2. // -// `.serialized`: every test here spawns a real `Process` with `Pipe`s and -// raw-reads the pipe file descriptors. Under Swift Testing's default parallel -// execution, a sibling test closing a `FileHandle` lets the OS recycle that fd -// number, so a background reader in another test can read a foreign stream -// (cross-wired stdout/stderr/stdin). These tests share the process-global fd -// table and are inherently not parallel-safe against each other. Serializing -// also matches production, where the runner executes strictly serially per -// coordinator, so this race window does not exist in the real app. -@Suite("RemoteSessionProcessRunner", .serialized) +// Nested under `RemoteSessionProcessTestIsolation` so this fd-closing +// regression suite cannot overlap with other process/pipe suites. +extension RemoteSessionProcessTestIsolation { +@Suite("RemoteSessionProcessRunner") struct RemoteSessionProcessRunnerTests { @Test("Capture survives the pipe read handles being torn down mid-run") func captureSurvivesPipeReadHandleTeardown() throws { @@ -110,3 +105,4 @@ struct RemoteSessionProcessRunnerTests { } } } +} diff --git a/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessTestIsolation.swift b/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessTestIsolation.swift new file mode 100644 index 000000000000..fcb3b96363be --- /dev/null +++ b/Packages/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionProcessTestIsolation.swift @@ -0,0 +1,7 @@ +import Testing + +// These suites spawn real Process/Pipe pairs and share the process-global file +// descriptor table. Keep them under one serialized parent so Swift Testing does +// not run fd-closing regression cases concurrently with other pipe captures. +@Suite("RemoteSession process tests", .serialized) +struct RemoteSessionProcessTestIsolation {} From 5b28db9cf948cef4480906e34785a233832b67c4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 14 Jun 2026 14:21:05 -0700 Subject: [PATCH 08/29] fix: make split resize shortcuts opt-in --- .github/swift-file-length-budget.tsv | 2 +- .../Values/ShortcutAction+Defaults.swift | 12 ++------ .../ShortcutActionTests.swift | 21 ++++++-------- Sources/KeyboardShortcutSettings.swift | 28 ++++--------------- ...pDelegateEqualizeSplitsShortcutTests.swift | 17 ++++++++++- ...dShortcutSettingsEqualizeSplitsTests.swift | 18 ++++++------ web/data/cmux-shortcuts.ts | 28 ++++++++++++++++--- 7 files changed, 65 insertions(+), 61 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d3f82f51f3ba..2b4d96694af2 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -35,7 +35,7 @@ 3200 Sources/Update/UpdateTitlebarAccessory.swift 2878 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift -2604 Sources/KeyboardShortcutSettings.swift +2586 Sources/KeyboardShortcutSettings.swift 2565 Sources/Panels/CmuxWebView.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift 2449 cmuxTests/CommandPaletteSearchEngineTests.swift diff --git a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 9a3ab2443be4..dd895d7f3ce0 100644 --- a/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -54,10 +54,8 @@ extension ShortcutAction { case .focusRight: return Self.shortcut(key: "→", command: true, option: true) case .focusUp: return Self.shortcut(key: "↑", command: true, option: true) case .focusDown: return Self.shortcut(key: "↓", command: true, option: true) - case .resizeSplitLeft: return Self.tmuxSplitResizeShortcut(chordKey: "←") - case .resizeSplitRight: return Self.tmuxSplitResizeShortcut(chordKey: "→") - case .resizeSplitUp: return Self.tmuxSplitResizeShortcut(chordKey: "↑") - case .resizeSplitDown: return Self.tmuxSplitResizeShortcut(chordKey: "↓") + case .resizeSplitLeft, .resizeSplitRight, .resizeSplitUp, .resizeSplitDown: + return nil case .splitRight: return Self.shortcut(key: "d", command: true) case .splitDown: return Self.shortcut(key: "d", command: true, shift: true) case .toggleSplitZoom: return Self.shortcut(key: "\r", command: true, shift: true) @@ -149,10 +147,4 @@ extension ShortcutAction { ) } - private static func tmuxSplitResizeShortcut(chordKey: String) -> StoredShortcut { - StoredShortcut( - first: ShortcutStroke(key: "b", control: true), - second: ShortcutStroke(key: chordKey, option: true) - ) - } } diff --git a/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift b/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift index cd5624975038..6db5e6892933 100644 --- a/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift +++ b/Packages/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionTests.swift @@ -4,25 +4,20 @@ import Testing @Suite("ShortcutAction") struct ShortcutActionTests { - @Test func resizeSplitActionsExposePaneGroupNamesAndChordDefaults() { - let expected: [(ShortcutAction, String, String)] = [ - (.resizeSplitLeft, "Resize Split Left", "←"), - (.resizeSplitRight, "Resize Split Right", "→"), - (.resizeSplitUp, "Resize Split Up", "↑"), - (.resizeSplitDown, "Resize Split Down", "↓"), + @Test func resizeSplitActionsExposePaneGroupNamesAndNoDefaults() { + let expected: [(ShortcutAction, String)] = [ + (.resizeSplitLeft, "Resize Split Left"), + (.resizeSplitRight, "Resize Split Right"), + (.resizeSplitUp, "Resize Split Up"), + (.resizeSplitDown, "Resize Split Down"), ] - for (action, displayName, chordKey) in expected { + for (action, displayName) in expected { #expect(ShortcutAction.allCases.contains(action)) #expect(action.group == .panes) #expect(action.displayName == displayName) #expect(action.defaultStroke == nil) - #expect( - action.defaultShortcut == StoredShortcut( - first: ShortcutStroke(key: "b", control: true), - second: ShortcutStroke(key: chordKey, option: true) - ) - ) + #expect(action.defaultShortcut == nil) } } } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f895087270c8..feb8cef81af9 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -425,14 +425,11 @@ 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) - case .resizeSplitLeft: - return Self.tmuxSplitResizeShortcut(chordKey: "←") - case .resizeSplitRight: - return Self.tmuxSplitResizeShortcut(chordKey: "→") - case .resizeSplitUp: - return Self.tmuxSplitResizeShortcut(chordKey: "↑") - case .resizeSplitDown: - return Self.tmuxSplitResizeShortcut(chordKey: "↓") + case .resizeSplitLeft, .resizeSplitRight, .resizeSplitUp, .resizeSplitDown: + // Unbound by default: a tmux-style Ctrl+B prefix would consume + // Ctrl+B before the focused terminal, shell, or nested tmux sees it. + // Users can still opt into chorded resize bindings via Settings or cmux.json. + 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) @@ -569,21 +566,6 @@ enum KeyboardShortcutSettings { } } - private static func tmuxSplitResizeShortcut(chordKey: String) -> StoredShortcut { - StoredShortcut( - key: "b", - command: false, - shift: false, - option: false, - control: true, - chordKey: chordKey, - chordCommand: false, - chordShift: false, - chordOption: true, - chordControl: false - ) - } - func tooltip(_ base: String) -> String { "\(base) (\(displayedShortcutString(for: KeyboardShortcutSettings.shortcut(for: self))))" } diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 07ed3fe40994..e03112541ddb 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -157,7 +157,7 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { } func testTmuxStyleResizeSplitShortcutMovesFocusedDividerAndUpdatesCachedLayout() { - withTemporaryShortcut(action: .resizeSplitRight) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: tmuxStyleResizeShortcut(chordKey: "→")) { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -375,6 +375,21 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { body() } + private func tmuxStyleResizeShortcut(chordKey: String) -> StoredShortcut { + StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: chordKey, + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + } + private func window(withId windowId: UUID) -> NSWindow? { let identifier = "cmux.main.\(windowId.uuidString)" return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) diff --git a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift index c4b686f05515..366c3b3051e1 100644 --- a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift @@ -23,19 +23,19 @@ final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { } } - func testResizeSplitDefaultsUseTmuxStyleArrowChords() { - let expected: [(KeyboardShortcutSettings.Action, String)] = [ - (.resizeSplitLeft, "←"), - (.resizeSplitRight, "→"), - (.resizeSplitUp, "↑"), - (.resizeSplitDown, "↓"), + func testResizeSplitDefaultsAreUnbound() { + let expected: [KeyboardShortcutSettings.Action] = [ + .resizeSplitLeft, + .resizeSplitRight, + .resizeSplitUp, + .resizeSplitDown, ] - for (action, chordKey) in expected { + for action in expected { XCTAssertEqual( action.defaultShortcut, - tmuxResizeShortcut(chordKey: chordKey), - "Expected \(action.rawValue) to use Ctrl+B then Option+\(chordKey)" + .unbound, + "Expected \(action.rawValue) to be opt-in so Ctrl+B reaches the focused terminal" ) } } diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index a0bfc8bc32aa..cad859854bea 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -176,10 +176,30 @@ 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: "resizeSplitLeft", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "←"]]], description: { en: "Resize split left", ja: "分割を左にリサイズ" } }, - { id: "resizeSplitRight", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "→"]]], description: { en: "Resize split right", ja: "分割を右にリサイズ" } }, - { id: "resizeSplitUp", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "↑"]]], description: { en: "Resize split up", ja: "分割を上にリサイズ" } }, - { id: "resizeSplitDown", combos: [], chordCombos: [[["⌃", "B"], ["⌥", "↓"]]], description: { en: "Resize split down", ja: "分割を下にリサイズ" } }, + { + id: "resizeSplitLeft", + combos: [], + description: { en: "Resize split left", ja: "分割を左にリサイズ" }, + note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + }, + { + id: "resizeSplitRight", + combos: [], + description: { en: "Resize split right", ja: "分割を右にリサイズ" }, + note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + }, + { + id: "resizeSplitUp", + combos: [], + description: { en: "Resize split up", ja: "分割を上にリサイズ" }, + note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + }, + { + id: "resizeSplitDown", + combos: [], + description: { en: "Resize split down", ja: "分割を下にリサイズ" }, + note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + }, { 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: "右にブラウザ分割" } }, From 7c310c6b12424a6a64557eeeddd2dce4cbe3588a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 00:05:42 -0700 Subject: [PATCH 09/29] test: cover shortcut chord mode controls --- .../ShortcutListModelTests.swift | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index 76de030dc97a..a469899fd254 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -365,6 +365,44 @@ import CmuxSettings #expect(model.bareKeyRejections.contains(action.rawValue)) } + @Test func chordModeToggleEnablesRecorderForChordCapableAction() { + // WHY: the Settings row needs an explicit way to switch its recorder + // from one stroke to a two-stroke chord. The post-refactor model kept + // chord assignment but lost the user-triggered insertion path. + let (store, catalog, errorLog) = makeStore() + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + let chordAction = ShortcutAction.resizeSplitRight + let singleStrokeOnlyAction = ShortcutAction.fileExplorerOpenSelection + + #expect(!model.chordsEnabled(for: chordAction)) + model.toggleChordMode(for: chordAction) + #expect(model.chordsEnabled(for: chordAction)) + model.toggleChordMode(for: chordAction) + #expect(!model.chordsEnabled(for: chordAction)) + + model.toggleChordMode(for: singleStrokeOnlyAction) + #expect(!model.chordsEnabled(for: singleStrokeOnlyAction)) + } + + @Test func existingChordKeepsRecorderInChordMode() async throws { + // WHY: reopening Settings for an existing chord must keep the recorder + // in chord mode so replacing it records both strokes instead of silently + // downgrading the binding to a single stroke. + let (store, catalog, errorLog) = makeStore() + let action = ShortcutAction.resizeSplitRight + let chord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + try await store.set([action.rawValue: chord], for: catalog.shortcuts.bindings) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[action.rawValue] == chord }) + + #expect(model.chordsEnabled(for: action)) + } + @Test func assignChordWritesValidTwoStrokeChord() async throws { // WHY: assignChord is the recorder's onChord path for chord-capable // actions (wired from ShortcutListRowView). Only its rejection branches From 8db27009093517bcb0aa96d3b748645f3afb3a63 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 00:17:05 -0700 Subject: [PATCH 10/29] fix: restore shortcut chord recording --- .../ShortcutListModel+RecorderState.swift | 29 +++++++++++++++++++ .../Bindings/ShortcutListModel.swift | 25 ++++++++-------- .../Rows/ShortcutListRowActions.swift | 1 + .../Rows/ShortcutListRowSnapshot.swift | 1 + .../Rows/ShortcutListRowView.swift | 14 +++++++++ .../Rows/ShortcutListStableLazyView.swift | 4 ++- .../Sections/GlobalHotkeySection.swift | 2 ++ .../ShortcutListModelTests.swift | 2 +- 8 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift new file mode 100644 index 000000000000..a3e84d21f105 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+RecorderState.swift @@ -0,0 +1,29 @@ +import CmuxSettings + +extension ShortcutListModel { + /// Whether the row recorder should collect two strokes for `action`. + /// + /// Existing chords stay in chord mode when Settings reopens. A user toggle + /// temporarily overrides that inferred state until the next assignment. + func chordsEnabled(for action: ShortcutAction) -> Bool { + guard action.allowsChordShortcut else { return false } + return chordModeOverrides[action.rawValue] ?? (effective(for: action)?.hasChord == true) + } + + /// Toggles whether the action's recorder collects a two-stroke chord. + func toggleChordMode(for action: ShortcutAction) { + guard action.allowsChordShortcut else { + setChordModeOverride(nil, for: action) + return + } + setChordModeOverride(!chordsEnabled(for: action), for: action) + } + + /// Whether `action` is currently unbound but has a cached stroke available to + /// restore (drives the X → restore button swap). + func canRestore(for action: ShortcutAction) -> Bool { + let effectiveShortcut = effective(for: action) + let isUnbound = effectiveShortcut?.isUnbound ?? true + return isUnbound && restoreShortcuts[action.rawValue] != nil + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift index 8c93e02ff466..69a3243dc385 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift @@ -14,7 +14,7 @@ final class ShortcutListModel { private(set) var legacyBindings: [String: StoredShortcut] private(set) var whenOverrideClauses: [String: ShortcutWhenClause] = [:] private(set) var whenOverrideRawStrings: [String: String] = [:] - private(set) var chordModeActions: Set = [] + private(set) var chordModeOverrides: [String: Bool] = [:] private(set) var restoreShortcuts: [String: StoredShortcut] = [:] private(set) var bareKeyRejections: Set = [] /// Per-action set marking a numbered action rejected for a non-`1…9` key. @@ -111,12 +111,8 @@ final class ShortcutListModel { latestBindings[action.rawValue] ?? legacyBindings[action.rawValue] ?? action.defaultShortcut } - /// Whether `action` is currently unbound but has a cached stroke available to - /// restore (drives the X → restore button swap). - func canRestore(for action: ShortcutAction) -> Bool { - let eff = effective(for: action) - let isUnbound = eff?.isUnbound ?? true - return isUnbound && restoreShortcuts[action.rawValue] != nil + func setChordModeOverride(_ enabled: Bool?, for action: ShortcutAction) { + chordModeOverrides[action.rawValue] = enabled } /// The red validation-banner text for `action` (bare-key, numbered-digit, or @@ -322,10 +318,12 @@ final class ShortcutListModel { rejectedConflictShortcuts[action.rawValue] = proposed bareKeyRejections.remove(action.rawValue) numberedDigitRejections.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } var updated = latestBindings updated[action.rawValue] = proposed + chordModeOverrides.removeValue(forKey: action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) numberedDigitRejections.remove(action.rawValue) @@ -339,17 +337,17 @@ final class ShortcutListModel { /// conflicts with another binding. func assignChord(_ chord: StoredShortcut, to action: ShortcutAction) async { guard action.allowsChordShortcut else { - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } guard action.allowsBareFirstStroke || chord.first.hasAnyModifier else { markBareKeyRejected(action) - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) return } guard let proposed = normalizedNumberedShortcutIfNeeded(chord, for: action) else { numberedDigitRejections.insert(action.rawValue) - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) conflictRejections.removeValue(forKey: action.rawValue) rejectedConflictShortcuts.removeValue(forKey: action.rawValue) @@ -358,14 +356,14 @@ final class ShortcutListModel { if let conflict = detectConflict(for: action, stroke: proposed) { conflictRejections[action.rawValue] = conflict rejectedConflictShortcuts[action.rawValue] = proposed - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) numberedDigitRejections.remove(action.rawValue) return } var updated = latestBindings updated[action.rawValue] = proposed - chordModeActions.remove(action.rawValue) + chordModeOverrides.removeValue(forKey: action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) numberedDigitRejections.remove(action.rawValue) @@ -378,6 +376,7 @@ final class ShortcutListModel { func clearBinding(for action: ShortcutAction) async { var updated = latestBindings updated[action.rawValue] = StoredShortcut.unbound + chordModeOverrides.removeValue(forKey: action.rawValue) await write(updated, clearingLegacyFor: action) } @@ -385,6 +384,7 @@ final class ShortcutListModel { func restoreBinding(_ shortcut: StoredShortcut, for action: ShortcutAction) async { var updated = latestBindings updated[action.rawValue] = shortcut + chordModeOverrides.removeValue(forKey: action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) bareKeyRejections.remove(action.rawValue) numberedDigitRejections.remove(action.rawValue) @@ -396,6 +396,7 @@ final class ShortcutListModel { /// Clears every override and all in-memory rejection/restore state — the /// "Reset Defaults" action. func resetAll() async { + chordModeOverrides.removeAll() restoreShortcuts.removeAll() bareKeyRejections.removeAll() numberedDigitRejections.removeAll() diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift index e258ee9b051b..73fb6098ca95 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowActions.swift @@ -5,6 +5,7 @@ struct ShortcutListRowActions { let onStroke: (ShortcutStroke) -> Void let onChord: (StoredShortcut) -> Void let onBareKeyRejected: () -> Void + let onToggleChordMode: () -> Void let onClearOrRestore: () -> Void let onClearRejections: () -> Void } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift index 4191a954333a..04cda80b5603 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowSnapshot.swift @@ -7,6 +7,7 @@ struct ShortcutListRowSnapshot: Equatable { let title: String let subtitle: String? let placeholder: String + let showsChordModeButton: Bool let chordsEnabled: Bool let hasPendingRejection: Bool let firstStrokeRequiresModifier: Bool diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift index 073c6f900c0a..d31da8af1759 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift @@ -52,6 +52,20 @@ struct ShortcutListRowView: View, Equatable { .frame(width: 160) .accessibilityIdentifier(snapshot.recorderAccessibilityIdentifier) + if snapshot.showsChordModeButton { + Button { + actions.onToggleChordMode() + } label: { + Image(systemName: "keyboard") + .imageScale(.medium) + .foregroundStyle(snapshot.chordsEnabled ? Color.accentColor : Color.secondary) + } + .buttonStyle(.borderless) + .help(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityLabel(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityIdentifier("ShortcutRecorderChordModeButton.\(snapshot.action.rawValue)") + } + Button { actions.onClearOrRestore() } label: { diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift index 298016067487..465aae6b2b50 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift @@ -22,7 +22,8 @@ struct ShortcutListStableLazyView: View { title: action.displayName, subtitle: model.scopeCaption(for: action), placeholder: model.formatPlaceholder(effective: effective, numbered: action.usesNumberedDigitMatching), - chordsEnabled: model.chordModeActions.contains(action.rawValue), + showsChordModeButton: action.allowsChordShortcut, + chordsEnabled: model.chordsEnabled(for: action), hasPendingRejection: model.bareKeyRejections.contains(action.rawValue) || model.numberedDigitRejections.contains(action.rawValue), firstStrokeRequiresModifier: !action.allowsBareFirstStroke, @@ -37,6 +38,7 @@ struct ShortcutListStableLazyView: View { onStroke: { stroke in Task { await model.assign(stroke: stroke, to: action) } }, onChord: { chord in Task { await model.assignChord(chord, to: action) } }, onBareKeyRejected: { model.markBareKeyRejected(action) }, + onToggleChordMode: { model.toggleChordMode(for: action) }, onClearOrRestore: { Task { await model.clearOrRestore(for: action) } }, onClearRejections: { model.clearRejections(for: action) } ) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift index 5376ab7e7f70..1803652ebde8 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -87,6 +87,7 @@ public struct GlobalHotkeySection: View { title: String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows"), subtitle: nil, placeholder: shortcutModel.formatPlaceholder(effective: effective, numbered: false), + showsChordModeButton: false, chordsEnabled: false, hasPendingRejection: shortcutModel.bareKeyRejections.contains(hotkeyAction.rawValue), firstStrokeRequiresModifier: true, @@ -99,6 +100,7 @@ public struct GlobalHotkeySection: View { onStroke: { stroke in Task { await shortcutModel.assign(stroke: stroke, to: hotkeyAction) } }, onChord: { _ in }, onBareKeyRejected: { shortcutModel.markBareKeyRejected(hotkeyAction) }, + onToggleChordMode: {}, onClearOrRestore: { Task { await shortcutModel.clearOrRestore(for: hotkeyAction) } }, onClearRejections: { shortcutModel.clearRejections(for: hotkeyAction) } ) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index a469899fd254..2292b94146f1 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -428,7 +428,7 @@ import CmuxSettings let storeBindings = await store.value(for: catalog.shortcuts.bindings) #expect(storeBindings[action.rawValue] == chord) // Happy path clears chord-mode arming and leaves no rejection state. - #expect(!model.chordModeActions.contains(action.rawValue)) + #expect(model.chordModeOverrides[action.rawValue] == nil) #expect(!model.numberedDigitRejections.contains(action.rawValue)) #expect(model.conflictRejections[action.rawValue] == nil) } From 96e1958d6375a50fd6a959eb3dff749b76ae244d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 02:44:18 -0700 Subject: [PATCH 11/29] test: avoid production shortcut debug seam --- Sources/AppDelegate.swift | 18 ------------------ ...ppDelegateEqualizeSplitsShortcutTests.swift | 12 ++++++++---- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3f7c0bc1bfdc..dd60f8bfd628 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -12817,24 +12817,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } -#if DEBUG - func debugSetGhosttyGotoSplitShortcut( - _ shortcut: StoredShortcut?, - direction: NavigationDirection - ) { - switch direction { - case .left: - ghosttyGotoSplitLeftShortcut = shortcut - case .right: - ghosttyGotoSplitRightShortcut = shortcut - case .up: - ghosttyGotoSplitUpShortcut = shortcut - case .down: - ghosttyGotoSplitDownShortcut = shortcut - } - } -#endif - private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { let tag: GhosttyTriggerInput.Tag switch trigger.tag { diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 392b9afa136b..3f91902f4d1e 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -198,12 +198,16 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { } #if DEBUG - appDelegate.debugSetGhosttyGotoSplitShortcut( - StoredShortcut(key: "→", command: false, shift: false, option: true, control: false), - direction: .right + let originalGhosttyGotoSplitRightShortcut = appDelegate.ghosttyGotoSplitRightShortcut + appDelegate.ghosttyGotoSplitRightShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: true, + control: false ) defer { - appDelegate.debugSetGhosttyGotoSplitShortcut(nil, direction: .right) + appDelegate.ghosttyGotoSplitRightShortcut = originalGhosttyGotoSplitRightShortcut } #endif From 0d09eccba66aea2fce44646c5788e0489ebd3170 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 03:01:02 -0700 Subject: [PATCH 12/29] fix: import workspace shell activity state --- Sources/DockSplitStore+RestoredAgentLifecycle.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/DockSplitStore+RestoredAgentLifecycle.swift b/Sources/DockSplitStore+RestoredAgentLifecycle.swift index 376402e702ac..a85031675f71 100644 --- a/Sources/DockSplitStore+RestoredAgentLifecycle.swift +++ b/Sources/DockSplitStore+RestoredAgentLifecycle.swift @@ -1,3 +1,4 @@ +import CmuxWorkspaces import Foundation extension DockSplitStore { From 181536aea9954af567f555ff0aa3dd435d2533f0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 03:05:52 -0700 Subject: [PATCH 13/29] fix: return resume approval policy --- Sources/ControlSurfaceResumeTarget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ControlSurfaceResumeTarget.swift b/Sources/ControlSurfaceResumeTarget.swift index d73324129026..52b0d4f1fcad 100644 --- a/Sources/ControlSurfaceResumeTarget.swift +++ b/Sources/ControlSurfaceResumeTarget.swift @@ -278,7 +278,7 @@ extension TerminalController { ) content.apply(to: alert, presentingWindow: nil) - switch alert.runModal() { + return switch alert.runModal() { case .alertFirstButtonReturn: .auto case .alertSecondButtonReturn: .prompt default: .manual From 19e8bbd72a5956c01e2a12fe81a0de0ef1ecbaf8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 03:08:00 -0700 Subject: [PATCH 14/29] fix: make session snapshot types explicit --- Sources/DockSplitStore+SessionSnapshot.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index ce3c31324931..8ae1a5f1a294 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -41,11 +41,12 @@ extension DockSplitStore { ) } let persistedPanelIds = Set(panelSnapshots.map(\.id)) - let sourceWorkspaceIdsByPanelId = Dictionary(uniqueKeysWithValues: panelSnapshots.compactMap { - panel in - guard let transfer = detachedSurfaceTransfersByPanelId[panel.id] else { return nil } - return (panel.id, transfer.sessionRestoreWorkspaceId) - }) + let sourceWorkspaceIdsByPanelId: [UUID: UUID] = Dictionary( + uniqueKeysWithValues: panelSnapshots.compactMap { panel -> (UUID, UUID)? in + guard let transfer = detachedSurfaceTransfersByPanelId[panel.id] else { return nil } + return (panel.id, transfer.sessionRestoreWorkspaceId) + } + ) let layout = layoutCodec.pruned( rawLayout, keeping: persistedPanelIds @@ -329,7 +330,7 @@ extension DockSplitStore { let expectedSessionId = resumeBinding?.isAgentHookBinding == true ? resumeBinding?.checkpointId : restorableAgent?.sessionId - let relevantObservation = observation.flatMap { entry in + let relevantObservation: RestorableAgentSessionIndex.Entry? = observation.flatMap { entry in guard entry.snapshot.kind == expectedKind, entry.snapshot.sessionId == expectedSessionId else { return nil } From dc10c561eea9bca5ad3bcaec80cd5124f77904a5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 03:27:34 -0700 Subject: [PATCH 15/29] test: cover shortcut review regressions --- .../ShortcutActionNumberedDigitTests.swift | 5 +- .../ShortcutListModelTests.swift | 51 +++++++ cmuxTests/DockShortcutRoutingTests.swift | 130 ++++++++++++++++++ ...dShortcutSettingsEqualizeSplitsTests.swift | 12 ++ 4 files changed, 197 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift index 6374bbe5bbcc..195b28ae3782 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift @@ -75,8 +75,11 @@ struct ShortcutActionNumberedDigitTests { } } - @Test func fileExplorerOpenSelectionShortcutsAreSingleStrokeOnly() { + @Test func unsupportedChordActionsAreSingleStrokeOnly() { #expect(!ShortcutAction.fileExplorerOpenSelection.allowsChordShortcut) #expect(!ShortcutAction.fileExplorerOpenSelectionFinderAlias.allowsChordShortcut) + #expect(!ShortcutAction.cycleTextBoxSubmitAction.allowsChordShortcut) + #expect(!ShortcutAction.showHideAllWindows.allowsChordShortcut) + #expect(!ShortcutAction.globalSearch.allowsChordShortcut) } } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index 2292b94146f1..8d7ff3428485 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -403,6 +403,57 @@ import CmuxSettings #expect(model.chordsEnabled(for: action)) } + @Test func chordBindingsCanSharePrefixWhenSecondStrokesDiffer() async throws { + // WHY: tmux-style shortcut families intentionally share one prefix. + // Conflict detection must compare the complete sequence, not reject the + // second action merely because both chords begin with Ctrl+B. + let (store, catalog, errorLog) = makeStore() + let rightAction = ShortcutAction.resizeSplitRight + let leftAction = ShortcutAction.resizeSplitLeft + let rightChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + let leftChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "←", option: true) + ) + try await store.set([rightAction.rawValue: rightChord], for: catalog.shortcuts.bindings) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[rightAction.rawValue] == rightChord }) + + await model.assignChord(leftChord, to: leftAction) + await spin(until: { model.bindings[leftAction.rawValue] == leftChord }) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[rightAction.rawValue] == rightChord) + #expect(storeBindings[leftAction.rawValue] == leftChord) + #expect(model.conflictRejections[leftAction.rawValue] == nil) + } + + @Test func identicalChordBindingIsRejected() async throws { + let (store, catalog, errorLog) = makeStore() + let conflictAction = ShortcutAction.resizeSplitRight + let targetAction = ShortcutAction.resizeSplitLeft + let chord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + try await store.set([conflictAction.rawValue: chord], for: catalog.shortcuts.bindings) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[conflictAction.rawValue] == chord }) + + await model.assignChord(chord, to: targetAction) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[targetAction.rawValue] == nil) + #expect(model.conflictRejections[targetAction.rawValue] == conflictAction) + } + @Test func assignChordWritesValidTwoStrokeChord() async throws { // WHY: assignChord is the recorder's onChord path for chord-capable // actions (wired from ShortcutListRowView). Only its rejection branches diff --git a/cmuxTests/DockShortcutRoutingTests.swift b/cmuxTests/DockShortcutRoutingTests.swift index 65bc9aec7493..739e8ef7a5ac 100644 --- a/cmuxTests/DockShortcutRoutingTests.swift +++ b/cmuxTests/DockShortcutRoutingTests.swift @@ -81,6 +81,127 @@ struct DockShortcutRoutingTests { } } + @Test("Customized resize shortcut moves only the focused Dock split") + @MainActor + func customizedResizeTargetsFocusedDock() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let leftPanel = try #require( + harness.dock.newSurface(kind: .terminal, inPane: harness.rootPane, focus: true) + ) + _ = try #require( + harness.dock.newSplit( + kind: .terminal, + orientation: .horizontal, + insertFirst: false, + sourcePanelId: leftPanel, + focus: true + ) + ) + let leftPane = try #require(harness.dock.paneId(forPanelId: leftPanel)) + harness.dock.focusPanel(leftPanel) + + let dockSplit = try #require(Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot())) + let dockSplitId = try #require(UUID(uuidString: dockSplit.id)) + #expect(harness.dock.bonsplitController.setDividerPosition(0.5, forSplit: dockSplitId)) + + let mainPanel = try #require(harness.mainWorkspace.focusedPanelId) + _ = try #require( + harness.mainWorkspace.newTerminalSplit( + from: mainPanel, + orientation: .horizontal + ) + ) + let mainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + let mainSplitId = try #require(UUID(uuidString: mainSplit.id)) + #expect( + harness.mainWorkspace.bonsplitController.setDividerPosition( + 0.35, + forSplit: mainSplitId + ) + ) + + let resize = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut(resize, for: .resizeSplitRight) + + #expect(Self.dispatch(resize, in: harness)) + + let resizedDockSplit = try #require( + Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) + ) + let unchangedMainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + #expect(resizedDockSplit.dividerPosition > 0.5) + #expect(abs(unchangedMainSplit.dividerPosition - 0.35) < 0.0001) + #expect(harness.dock.focusedPanelId == leftPanel) + #expect(harness.dock.bonsplitController.focusedPaneId == leftPane) + } + } + } + + @Test("Resize at a missing Dock border is consumed without falling through") + @MainActor + func resizeAtMissingDockBorderDoesNotResizeMainSplit() async throws { + try await AppContextSerialGate.withExclusiveAppContext { + try Self.withHarness { harness in + let leftDockPanel = try #require( + harness.dock.newSurface(kind: .terminal, inPane: harness.rootPane, focus: true) + ) + _ = try #require( + harness.dock.newSplit( + kind: .terminal, + orientation: .horizontal, + insertFirst: false, + sourcePanelId: leftDockPanel, + focus: true + ) + ) + harness.dock.focusPanel(leftDockPanel) + let dockSplit = try #require( + Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) + ) + let dockSplitId = try #require(UUID(uuidString: dockSplit.id)) + #expect(harness.dock.bonsplitController.setDividerPosition(0.4, forSplit: dockSplitId)) + + let leftMainPanel = try #require(harness.mainWorkspace.focusedPanelId) + let rightMainPanel = try #require( + harness.mainWorkspace.newTerminalSplit( + from: leftMainPanel, + orientation: .horizontal + ) + ) + #expect(harness.mainWorkspace.focusedPanelId == rightMainPanel.id) + let mainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + let mainSplitId = try #require(UUID(uuidString: mainSplit.id)) + #expect( + harness.mainWorkspace.bonsplitController.setDividerPosition( + 0.6, + forSplit: mainSplitId + ) + ) + + let resizeLeft = Self.customShortcut(key: "y") + KeyboardShortcutSettings.setShortcut(resizeLeft, for: .resizeSplitLeft) + + #expect(Self.dispatch(resizeLeft, in: harness)) + + let unchangedDockSplit = try #require( + Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) + ) + let unchangedMainSplit = try #require( + Self.firstSplit(in: harness.mainWorkspace.bonsplitController.treeSnapshot()) + ) + #expect(abs(unchangedDockSplit.dividerPosition - 0.4) < 0.0001) + #expect(abs(unchangedMainSplit.dividerPosition - 0.6) < 0.0001) + } + } + } + @Test("Legacy tab shortcuts target the focused Dock") @MainActor func legacyTabShortcutsTargetFocusedDock() async throws { @@ -425,6 +546,15 @@ private extension DockShortcutRoutingTests { control: true ) } + + static func firstSplit(in node: ExternalTreeNode) -> ExternalSplitNode? { + switch node { + case .pane: + return nil + case .split(let split): + return split + } + } } @MainActor diff --git a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift index 366c3b3051e1..14a72e33bdd7 100644 --- a/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsEqualizeSplitsTests.swift @@ -40,6 +40,18 @@ final class KeyboardShortcutSettingsEqualizeSplitsTests: XCTestCase { } } + func testSystemWideShortcutActionsDoNotAllowChords() { + let actions: [(KeyboardShortcutSettings.Action, ShortcutAction)] = [ + (.showHideAllWindows, .showHideAllWindows), + (.globalSearch, .globalSearch), + ] + + for (appAction, packageAction) in actions { + XCTAssertFalse(appAction.allowsChordShortcut) + XCTAssertFalse(packageAction.allowsChordShortcut) + } + } + func testSettingsFileStoreParsesEqualizeSplitsShortcut() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } From 490b4cffe49929dcabc2397ec0b984585bce4234 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 03:51:41 -0700 Subject: [PATCH 16/29] fix: close split shortcut review gaps --- .../CmuxSettings/Values/ShortcutAction.swift | 2 + .../Bindings/ShortcutListModel.swift | 6 +-- .../Sections/ShortcutConflict.swift | 52 +++++++++++++++++++ Sources/AppDelegate+DockShortcutRouting.swift | 11 +++- Sources/AppDelegate.swift | 6 +++ Sources/DockSplitStore+ShortcutCommands.swift | 13 +++++ Sources/KeyboardShortcutSettings.swift | 6 ++- 7 files changed, 90 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 3406933768fa..c47009dad477 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -275,6 +275,8 @@ extension ShortcutAction { self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction + && self != .showHideAllWindows + && self != .globalSearch } /// The action's built-in focus context expressed as a ``ShortcutWhenClause``, diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift index 69a3243dc385..fd0e78bff3e6 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift @@ -234,10 +234,10 @@ final class ShortcutListModel { ) else { continue } let effective = effective(for: other) guard let effective, !effective.isUnbound else { continue } - if numberedAwareStrokesConflict( - stroke.first, + if shortcutSequencesConflict( + stroke, numbered: action.usesNumberedDigitMatching, - effective.first, + effective, numbered: other.usesNumberedDigitMatching ) { return other diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift index 66a55158b232..9717441f2c40 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift @@ -43,6 +43,58 @@ func numberedAwareStrokesConflict( return lhs.key == rhs.key && sameModifiers(lhs, rhs) } +/// Whether two complete shortcut bindings collide under the app runtime's +/// chord-prefix semantics. +func shortcutSequencesConflict( + _ lhs: StoredShortcut, + numbered lhsNumbered: Bool, + _ rhs: StoredShortcut, + numbered rhsNumbered: Bool +) -> Bool { + guard !lhs.isUnbound, !rhs.isUnbound else { return false } + + switch (lhs.hasChord, rhs.hasChord) { + case (false, false): + return numberedAwareStrokesConflict( + lhs.first, + numbered: lhsNumbered, + rhs.first, + numbered: rhsNumbered + ) + case (true, true): + guard numberedAwareStrokesConflict( + lhs.first, + numbered: false, + rhs.first, + numbered: false + ), + let lhsSecond = lhs.second, + let rhsSecond = rhs.second else { + return false + } + return numberedAwareStrokesConflict( + lhsSecond, + numbered: lhsNumbered, + rhsSecond, + numbered: rhsNumbered + ) + case (true, false): + return numberedAwareStrokesConflict( + lhs.first, + numbered: false, + rhs.first, + numbered: rhsNumbered + ) + case (false, true): + return numberedAwareStrokesConflict( + lhs.first, + numbered: lhsNumbered, + rhs.first, + numbered: false + ) + } +} + private func sameModifiers(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool { lhs.command == rhs.command && lhs.shift == rhs.shift diff --git a/Sources/AppDelegate+DockShortcutRouting.swift b/Sources/AppDelegate+DockShortcutRouting.swift index d8141f2c1e3b..e5cb0595424d 100644 --- a/Sources/AppDelegate+DockShortcutRouting.swift +++ b/Sources/AppDelegate+DockShortcutRouting.swift @@ -81,8 +81,11 @@ extension AppDelegate { /// focus. Callers invoke this from the command's existing dispatcher /// position so configured and compatibility shortcuts keep the same /// conflict precedence as the main area. - func performFocusedDockShortcut(_ command: DockShortcutCommand, event: NSEvent) -> Bool { - guard let store = focusedDockStoreForShortcut(preferredWindow: event.window) else { + func performFocusedDockShortcut( + _ command: DockShortcutCommand, + preferredWindow: NSWindow? + ) -> Bool { + guard let store = focusedDockStoreForShortcut(preferredWindow: preferredWindow) else { return false } if command.isFocusHistoryNavigation, !store.focusHistoryIncludesPanesAndTabs { @@ -92,6 +95,10 @@ extension AppDelegate { return true } + func performFocusedDockShortcut(_ command: DockShortcutCommand, event: NSEvent) -> Bool { + performFocusedDockShortcut(command, preferredWindow: event.window) + } + func matchesLegacyNextSurfaceShortcut(event: NSEvent) -> Bool { matchTabShortcut( event: event, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index dd60f8bfd628..a6c6647a3eb5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14863,6 +14863,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent preferredWindow: NSWindow? = nil ) -> Bool { let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if performFocusedDockShortcut( + .resizeSplit(direction, amount: splitResizeShortcutStepPixels), + preferredWindow: targetWindow + ) { + return true + } let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) let routedManager = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) diff --git a/Sources/DockSplitStore+ShortcutCommands.swift b/Sources/DockSplitStore+ShortcutCommands.swift index a4d58e93cd07..a8cb8c9a6b47 100644 --- a/Sources/DockSplitStore+ShortcutCommands.swift +++ b/Sources/DockSplitStore+ShortcutCommands.swift @@ -1,4 +1,5 @@ import Bonsplit +import CmuxPanes import CmuxWorkspaces import Foundation @@ -8,6 +9,7 @@ enum DockShortcutCommand { case selectSurface(number: Int) case moveSurface(offset: Int) case focusPane(NavigationDirection) + case resizeSplit(ResizeDirection, amount: UInt16) case togglePaneZoom case focusHistoryBack case focusHistoryForward @@ -46,6 +48,17 @@ extension DockSplitStore { bonsplitController.navigateFocus(direction: direction) applyFocusedShortcutSelection() return true + case .resizeSplit(let direction, let amount): + guard amount > 0, + let focusedPanelId, + let paneId = paneId(forPanelId: focusedPanelId) else { return false } + return PaneLayoutService().resizeSplit( + in: bonsplitController.treeSnapshot(), + targetPaneId: paneId.id.uuidString, + direction: direction, + amountPixels: amount, + controller: bonsplitController + ) case .togglePaneZoom: guard let pane = bonsplitController.focusedPaneId else { return false } return toggleDockPaneZoom(inPane: pane) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 4c0108fa2f53..aa1298a5cf5f 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -642,7 +642,11 @@ enum KeyboardShortcutSettings { } var allowsChordShortcut: Bool { - self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction + self != .fileExplorerOpenSelection + && self != .fileExplorerOpenSelectionFinderAlias + && self != .cycleTextBoxSubmitAction + && self != .showHideAllWindows + && self != .globalSearch } func displayedShortcutString(for shortcut: StoredShortcut) -> String { From 3f6b065f2145d9ca4966164e873c29b6f3c7140c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 04:11:04 -0700 Subject: [PATCH 17/29] test: update Dock transfer fixture --- cmuxTests/DockWorkingDirectoryInheritanceTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/DockWorkingDirectoryInheritanceTests.swift b/cmuxTests/DockWorkingDirectoryInheritanceTests.swift index 92d89005d561..cbf661b341eb 100644 --- a/cmuxTests/DockWorkingDirectoryInheritanceTests.swift +++ b/cmuxTests/DockWorkingDirectoryInheritanceTests.swift @@ -214,6 +214,7 @@ struct DockWorkingDirectoryInheritanceTests { ) -> Workspace.DetachedSurfaceTransfer { Workspace.DetachedSurfaceTransfer( sourceWorkspaceId: sourceWorkspaceId, + sessionRestoreSourceWorkspaceId: nil, panelId: panel.id, panel: panel, title: panel.displayTitle, From adb560232f9dd715bcd7401baf087bdedd94bac9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 04:28:42 -0700 Subject: [PATCH 18/29] test: cover resize shortcut Ghostty precedence --- ...pDelegateEqualizeSplitsShortcutTests.swift | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 3f91902f4d1e..45e35d4653f9 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -263,6 +263,140 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { } } + func testSingleStrokeResizeShortcutPrecedesGhosttyGotoSplitFallback() { + let resizeShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: true, + control: false + ) + withTemporaryShortcut(action: .resizeSplitRight, shortcut: resizeShortcut) { + 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 workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id), + let event = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and Option+Right event") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + let originalGhosttyGotoSplitRightShortcut = appDelegate.ghosttyGotoSplitRightShortcut + appDelegate.ghosttyGotoSplitRightShortcut = resizeShortcut + defer { + appDelegate.ghosttyGotoSplitRightShortcut = originalGhosttyGotoSplitRightShortcut + } + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + } + } + + func testExplicitFocusShortcutPrecedesConfiguredResizeShortcut() { + let sharedShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: true, + control: false + ) + withTemporaryShortcut(action: .focusRight, shortcut: sharedShortcut) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: sharedShortcut) { + 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 workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit( + from: leftPanelId, + orientation: .horizontal + ) else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first, + let splitId = UUID(uuidString: split.id), + let event = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and Option+Right event") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let routedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected routed split node") + return + } + XCTAssertEqual(routedSplit.dividerPosition, 0.5, accuracy: 0.000_1) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + } + } + } + private func shortcutRoutingSplitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { switch node { case .pane: From fbd776d2a66db3dbd9f1a5189a8704527a0dbe8a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 04:44:07 -0700 Subject: [PATCH 19/29] fix: prioritize configured split resize shortcuts --- Sources/AppDelegate.swift | 57 ++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a6c6647a3eb5..27a3e78ded2c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13862,6 +13862,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + let matchesResizeSplitLeft = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) + let matchesResizeSplitRight = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) + let matchesResizeSplitUp = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) + let matchesResizeSplitDown = matchConfiguredDirectionalShortcut( + event: event, + action: .resizeSplitDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) + // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). let shouldMatchGhosttyGotoSplitFallback = activeConfiguredShortcutChordPrefixForCurrentEvent == nil if matchConfiguredDirectionalShortcut( @@ -13871,6 +13896,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: 123 ) || ( shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitLeft && matchesGhosttyGotoSplitShortcut(event: event, direction: .left) ) { if performFocusedDockShortcut(.focusPane(.left), event: event) { return true } @@ -13889,6 +13915,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: 124 ) || ( shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitRight && matchesGhosttyGotoSplitShortcut(event: event, direction: .right) ) { if performFocusedDockShortcut(.focusPane(.right), event: event) { return true } @@ -13907,6 +13934,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: 126 ) || ( shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitUp && matchesGhosttyGotoSplitShortcut(event: event, direction: .up) ) { if performFocusedDockShortcut(.focusPane(.up), event: event) { return true } @@ -13925,6 +13953,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: 125 ) || ( shouldMatchGhosttyGotoSplitFallback && + !matchesResizeSplitDown && matchesGhosttyGotoSplitShortcut(event: event, direction: .down) ) { if performFocusedDockShortcut(.focusPane(.down), event: event) { return true } @@ -13937,12 +13966,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredDirectionalShortcut( - event: event, - action: .resizeSplitLeft, - arrowGlyph: "←", - arrowKeyCode: 123 - ) { + if matchesResizeSplitLeft { _ = performResizeSplitShortcut( direction: .left, preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow @@ -13950,12 +13974,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredDirectionalShortcut( - event: event, - action: .resizeSplitRight, - arrowGlyph: "→", - arrowKeyCode: 124 - ) { + if matchesResizeSplitRight { _ = performResizeSplitShortcut( direction: .right, preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow @@ -13963,12 +13982,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredDirectionalShortcut( - event: event, - action: .resizeSplitUp, - arrowGlyph: "↑", - arrowKeyCode: 126 - ) { + if matchesResizeSplitUp { _ = performResizeSplitShortcut( direction: .up, preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow @@ -13976,12 +13990,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredDirectionalShortcut( - event: event, - action: .resizeSplitDown, - arrowGlyph: "↓", - arrowKeyCode: 125 - ) { + if matchesResizeSplitDown { _ = performResizeSplitShortcut( direction: .down, preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow From 2c15bae17fbd06127ae2fa368d32dd7747e3272b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 04:53:56 -0700 Subject: [PATCH 20/29] fix: localize shortcut chord control --- Resources/Localizable.xcstrings | 102 ++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 01ee28fde383..e21353ce5c54 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -182429,23 +182429,125 @@ "settings.shortcuts.chords": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تسلسلات الاختصارات" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sekvence prečica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Genvejssekvenser" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastenkürzel-Sequenzen" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Shortcut Chords" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Secuencias de atajos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Séquences de raccourcis" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sequenze di scorciatoie" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ショートカットコード" } }, + "km": { + "stringUnit": { + "state": "translated", + "value": "លំដាប់ផ្លូវកាត់" + } + }, "ko": { "stringUnit": { "state": "translated", "value": "단축키 조합" } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Snarveissekvenser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Sekwencje skrótów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Sequências de atalhos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Последовательности сочетаний клавиш" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลำดับปุ่มลัด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kısayol dizileri" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Послідовності клавіатурних скорочень" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快捷键序列" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快捷鍵序列" + } } } }, From 6a43d9d7ea93d4a0b7641bdb61330cd40989d1a2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 05:03:42 -0700 Subject: [PATCH 21/29] test: cover mixed-metadata chord prefixes --- ...pDelegateEqualizeSplitsShortcutTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 45e35d4653f9..1ef0305a40a1 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -397,6 +397,96 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { } } + func testSharedChordPrefixIgnoresRecordedKeyCodeMetadataAtRuntime() { + let recordedFocusShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + keyCode: 11, + chordKey: "←", + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + let configuredResizeShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "→", + chordCommand: false, + chordShift: false, + chordOption: true, + chordControl: false + ) + withTemporaryShortcut(action: .focusRight, shortcut: recordedFocusShortcut) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: configuredResizeShortcut) { + 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 workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first, + let splitId = UUID(uuidString: split.id), + let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ), + let resizeEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.option], + keyCode: 124, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and mixed-metadata chord events") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + } + } + } + private func shortcutRoutingSplitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { switch node { case .pane: From 5647bb68c2a358a14533a87a73ee34d244200c73 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 05:14:46 -0700 Subject: [PATCH 22/29] fix: close shortcut review gaps --- .../Rows/ShortcutListRowView.swift | 1 + Sources/AppDelegate.swift | 14 +- web/data/cmux-shortcuts.ts | 132 ++++++++++++++++-- 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift index d31da8af1759..afd6dc4cc41c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListRowView.swift @@ -63,6 +63,7 @@ struct ShortcutListRowView: View, Equatable { .buttonStyle(.borderless) .help(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) .accessibilityLabel(String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords")) + .accessibilityAddTraits(snapshot.chordsEnabled ? .isSelected : []) .accessibilityIdentifier("ShortcutRecorderChordModeButton.\(snapshot.action.rawValue)") } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 27a3e78ded2c..000325755be9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15274,11 +15274,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + private func configuredShortcutStrokesMatch(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool { + lhs.key == rhs.key + && lhs.command == rhs.command + && lhs.shift == rhs.shift + && lhs.option == rhs.option + && lhs.control == rhs.control + } + private func matchConfiguredShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + configuredShortcutStrokesMatch(shortcut.firstStroke, prefix) else { return false } return matchShortcutStroke(event: event, stroke: secondStroke) @@ -15325,7 +15333,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !shortcut.isUnbound else { return nil } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + configuredShortcutStrokesMatch(shortcut.firstStroke, prefix) else { return nil } return numberedShortcutDigit(event: event, stroke: secondStroke) @@ -15388,7 +15396,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !shortcut.isUnbound else { return false } if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + configuredShortcutStrokesMatch(shortcut.firstStroke, prefix) else { return false } return matchDirectionalShortcut( diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 055eb5eac14d..3cbb9fb6600e 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -5,6 +5,122 @@ export type LocalizedText = { ja: string; } & Partial, string>>; +type ResizeSplitDirection = "left" | "right" | "up" | "down"; + +const resizeSplitDescriptions = { + left: { + en: "Resize split left", + ja: "分割を左にリサイズ", + "zh-CN": "向左调整分割大小", + "zh-TW": "向左調整分割大小", + ko: "분할 크기를 왼쪽으로 조정", + de: "Teilung nach links anpassen", + es: "Redimensionar la división hacia la izquierda", + fr: "Redimensionner la division vers la gauche", + it: "Ridimensiona la divisione verso sinistra", + da: "Tilpas opdelingen mod venstre", + pl: "Zmień rozmiar podziału w lewo", + ru: "Изменить размер разделения влево", + bs: "Promijeni veličinu podjele ulijevo", + ar: "تغيير حجم الجزء نحو اليسار", + no: "Endre størrelsen på delingen mot venstre", + "pt-BR": "Redimensionar a divisão para a esquerda", + th: "ปรับขนาดช่องแบ่งไปทางซ้าย", + tr: "Bölmeyi sola doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកទៅឆ្វេង", + uk: "Змінити розмір поділу вліво", + }, + right: { + en: "Resize split right", + ja: "分割を右にリサイズ", + "zh-CN": "向右调整分割大小", + "zh-TW": "向右調整分割大小", + ko: "분할 크기를 오른쪽으로 조정", + de: "Teilung nach rechts anpassen", + es: "Redimensionar la división hacia la derecha", + fr: "Redimensionner la division vers la droite", + it: "Ridimensiona la divisione verso destra", + da: "Tilpas opdelingen mod højre", + pl: "Zmień rozmiar podziału w prawo", + ru: "Изменить размер разделения вправо", + bs: "Promijeni veličinu podjele udesno", + ar: "تغيير حجم الجزء نحو اليمين", + no: "Endre størrelsen på delingen mot høyre", + "pt-BR": "Redimensionar a divisão para a direita", + th: "ปรับขนาดช่องแบ่งไปทางขวา", + tr: "Bölmeyi sağa doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកទៅស្ដាំ", + uk: "Змінити розмір поділу вправо", + }, + up: { + en: "Resize split up", + ja: "分割を上にリサイズ", + "zh-CN": "向上调整分割大小", + "zh-TW": "向上調整分割大小", + ko: "분할 크기를 위쪽으로 조정", + de: "Teilung nach oben anpassen", + es: "Redimensionar la división hacia arriba", + fr: "Redimensionner la division vers le haut", + it: "Ridimensiona la divisione verso l'alto", + da: "Tilpas opdelingen opad", + pl: "Zmień rozmiar podziału w górę", + ru: "Изменить размер разделения вверх", + bs: "Promijeni veličinu podjele prema gore", + ar: "تغيير حجم الجزء نحو الأعلى", + no: "Endre størrelsen på delingen oppover", + "pt-BR": "Redimensionar a divisão para cima", + th: "ปรับขนาดช่องแบ่งขึ้น", + tr: "Bölmeyi yukarı doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកឡើងលើ", + uk: "Змінити розмір поділу вгору", + }, + down: { + en: "Resize split down", + ja: "分割を下にリサイズ", + "zh-CN": "向下调整分割大小", + "zh-TW": "向下調整分割大小", + ko: "분할 크기를 아래쪽으로 조정", + de: "Teilung nach unten anpassen", + es: "Redimensionar la división hacia abajo", + fr: "Redimensionner la division vers le bas", + it: "Ridimensiona la divisione verso il basso", + da: "Tilpas opdelingen nedad", + pl: "Zmień rozmiar podziału w dół", + ru: "Изменить размер разделения вниз", + bs: "Promijeni veličinu podjele prema dolje", + ar: "تغيير حجم الجزء نحو الأسفل", + no: "Endre størrelsen på delingen nedover", + "pt-BR": "Redimensionar a divisão para baixo", + th: "ปรับขนาดช่องแบ่งลง", + tr: "Bölmeyi aşağı doğru yeniden boyutlandır", + km: "ប្ដូរទំហំផ្នែកបែងចែកចុះក្រោម", + uk: "Змінити розмір поділу вниз", + }, +} satisfies Record>; + +const resizeSplitUnboundNote = { + en: "unbound by default", + ja: "デフォルトでは未割り当て", + "zh-CN": "默认未绑定", + "zh-TW": "預設未綁定", + ko: "기본적으로 할당되지 않음", + de: "standardmäßig nicht belegt", + es: "sin asignar de forma predeterminada", + fr: "non attribué par défaut", + it: "non assegnato per impostazione predefinita", + da: "ikke tildelt som standard", + pl: "domyślnie nieprzypisany", + ru: "по умолчанию не назначено", + bs: "podrazumijevano nije dodijeljeno", + ar: "غير معيّن افتراضيًا", + no: "ikke tilordnet som standard", + "pt-BR": "não atribuído por padrão", + th: "ไม่ได้กำหนดไว้โดยค่าเริ่มต้น", + tr: "varsayılan olarak atanmamış", + km: "មិនបានកំណត់តាមលំនាំដើម", + uk: "типово не призначено", +} satisfies Record; + export function localizedShortcutText(text: LocalizedText, locale: string) { return text[locale as keyof LocalizedText] ?? (locale.startsWith("ja") ? text.ja : text.en); } @@ -306,26 +422,26 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "resizeSplitLeft", combos: [], - description: { en: "Resize split left", ja: "分割を左にリサイズ" }, - note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + description: resizeSplitDescriptions.left, + note: resizeSplitUnboundNote, }, { id: "resizeSplitRight", combos: [], - description: { en: "Resize split right", ja: "分割を右にリサイズ" }, - note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + description: resizeSplitDescriptions.right, + note: resizeSplitUnboundNote, }, { id: "resizeSplitUp", combos: [], - description: { en: "Resize split up", ja: "分割を上にリサイズ" }, - note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + description: resizeSplitDescriptions.up, + note: resizeSplitUnboundNote, }, { id: "resizeSplitDown", combos: [], - description: { en: "Resize split down", ja: "分割を下にリサイズ" }, - note: { en: "unbound by default", ja: "デフォルトでは未割り当て" }, + description: resizeSplitDescriptions.down, + note: resizeSplitUnboundNote, }, { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, From a9038ce45cffd0367d34cdb56193866875e65a13 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 05:23:09 -0700 Subject: [PATCH 23/29] test: cover shortcut review regressions --- cmuxTests/DockShortcutRoutingTests.swift | 2 ++ web/tests/keyboard-shortcuts-data.test.ts | 30 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 web/tests/keyboard-shortcuts-data.test.ts diff --git a/cmuxTests/DockShortcutRoutingTests.swift b/cmuxTests/DockShortcutRoutingTests.swift index 739e8ef7a5ac..d4fd0879cbae 100644 --- a/cmuxTests/DockShortcutRoutingTests.swift +++ b/cmuxTests/DockShortcutRoutingTests.swift @@ -125,8 +125,10 @@ struct DockShortcutRoutingTests { let resize = Self.customShortcut(key: "y") KeyboardShortcutSettings.setShortcut(resize, for: .resizeSplitRight) + harness.dock.dockPortalReconcileState.scheduledRequestCount = 0 #expect(Self.dispatch(resize, in: harness)) + #expect(harness.dock.dockPortalReconcileState.scheduledRequestCount > 0) let resizedDockSplit = try #require( Self.firstSplit(in: harness.dock.bonsplitController.treeSnapshot()) diff --git a/web/tests/keyboard-shortcuts-data.test.ts b/web/tests/keyboard-shortcuts-data.test.ts new file mode 100644 index 000000000000..bb23d95447fc --- /dev/null +++ b/web/tests/keyboard-shortcuts-data.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { + shortcutCategories, + shortcutSequences, + type ShortcutSequence, +} from "../data/cmux-shortcuts"; + +const shortcutsById = new Map( + shortcutCategories + .flatMap((category) => category.shortcuts) + .map((shortcut) => [shortcut.id, shortcut]), +); + +describe("keyboard shortcut sequence data", () => { + const cases = [ + ["diffViewerScrollToTop", [[["G"], ["G"]]]], + ["diffViewerNextFile", [[["]"], ["F"]]]], + ["diffViewerPreviousFile", [[["["], ["F"]]]], + ] satisfies Array<[string, ShortcutSequence[]]>; + + for (const [id, expectedSequences] of cases) { + test(`${id} exposes its strokes as an ordered chord`, () => { + const shortcut = shortcutsById.get(id); + + expect(shortcut).toBeDefined(); + expect(shortcutSequences(shortcut!)).toEqual(expectedSequences); + }); + } +}); From c42a36db534000927cbf186906dbecf2ad8301e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 05:41:47 -0700 Subject: [PATCH 24/29] fix: close final shortcut review gaps --- Sources/DockSplitStore+ShortcutCommands.swift | 6 +++++- web/data/cmux-shortcuts.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/DockSplitStore+ShortcutCommands.swift b/Sources/DockSplitStore+ShortcutCommands.swift index a8cb8c9a6b47..10d0c78ec163 100644 --- a/Sources/DockSplitStore+ShortcutCommands.swift +++ b/Sources/DockSplitStore+ShortcutCommands.swift @@ -52,13 +52,17 @@ extension DockSplitStore { guard amount > 0, let focusedPanelId, let paneId = paneId(forPanelId: focusedPanelId) else { return false } - return PaneLayoutService().resizeSplit( + let didResize = PaneLayoutService().resizeSplit( in: bonsplitController.treeSnapshot(), targetPaneId: paneId.id.uuidString, direction: direction, amountPixels: amount, controller: bonsplitController ) + if didResize { + scheduleDockPortalReconcile(reason: "dock.shortcut.resize") + } + return didResize case .togglePaneZoom: guard let pane = bonsplitController.focusedPaneId else { return false } return toggleDockPaneZoom(inPane: pane) diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 3cbb9fb6600e..5630681a506c 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -626,7 +626,8 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "diffViewerScrollToTop", - combos: [["G", "G"]], + combos: [], + chordCombos: [[["G"], ["G"]]], description: { en: "Scroll diff to top", ja: "差分の先頭へスクロール" }, note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, configValue: '["g", "g"]', @@ -639,14 +640,16 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "diffViewerNextFile", - combos: [["]", "F"]], + combos: [], + chordCombos: [[["]"], ["F"]]], description: { en: "Jump to next diff file", ja: "次の差分ファイルへ移動" }, note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, configValue: '["]", "f"]', }, { id: "diffViewerPreviousFile", - combos: [["[", "F"]], + combos: [], + chordCombos: [[["["], ["F"]]], description: { en: "Jump to previous diff file", ja: "前の差分ファイルへ移動" }, note: { en: "focused diff viewer", ja: "フォーカス中の差分ビューア" }, configValue: '["[", "f"]]', From 1228d151aabeb02b28eb96367567aaa16177853e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 06:09:18 -0700 Subject: [PATCH 25/29] test: cover recorded arrow shortcut conflicts --- .../ShortcutListModelTests.swift | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index 8d7ff3428485..142b0f2d2485 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -454,6 +454,64 @@ import CmuxSettings #expect(model.conflictRejections[targetAction.rawValue] == conflictAction) } + @Test func recordedPrivateUseArrowChordConflictsWithGlyphChord() async throws { + // NSEvent.charactersIgnoringModifiers reports arrow keys as AppKit + // private-use scalars, while cmux.json and defaults use arrow glyphs. + // The recorder must not persist a physically identical chord that the + // runtime will route to the already-bound action. + let (store, catalog, errorLog) = makeStore() + let conflictAction = ShortcutAction.resizeSplitRight + let targetAction = ShortcutAction.resizeSplitLeft + let configuredChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "→", option: true) + ) + let recordedChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true, keyCode: 11), + second: ShortcutStroke(key: "\u{F703}", option: true, keyCode: 124) + ) + try await store.set( + [conflictAction.rawValue: configuredChord], + for: catalog.shortcuts.bindings + ) + + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[conflictAction.rawValue] == configuredChord }) + + await model.assignChord(recordedChord, to: targetAction) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[targetAction.rawValue] == nil) + #expect(model.conflictRejections[targetAction.rawValue] == conflictAction) + } + + @Test(arguments: [ + ("\u{F702}", "←", UInt16(123)), + ("\u{F703}", "→", UInt16(124)), + ("\u{F700}", "↑", UInt16(126)), + ("\u{F701}", "↓", UInt16(125)), + ]) + func privateUseArrowKeysConflictWithCanonicalGlyphs( + privateUseKey: String, + canonicalKey: String, + keyCode: UInt16 + ) { + let recorded = ShortcutStroke( + key: privateUseKey, + option: true, + keyCode: keyCode + ) + let configured = ShortcutStroke(key: canonicalKey, option: true) + + #expect(numberedAwareStrokesConflict( + recorded, + numbered: false, + configured, + numbered: false + )) + } + @Test func assignChordWritesValidTwoStrokeChord() async throws { // WHY: assignChord is the recorder's onChord path for chord-capable // actions (wired from ShortcutListRowView). Only its rejection branches From bcfe7a31082630182132012e0d83875faede033a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 06:10:04 -0700 Subject: [PATCH 26/29] fix: canonicalize recorded arrow shortcut conflicts --- .../Sections/ShortcutConflict.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift index 9717441f2c40..fe3edb8053da 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift @@ -40,7 +40,8 @@ func numberedAwareStrokesConflict( // keystroke can be stored with or without a resolved virtual key code (e.g. // recorded vs. hand-written cmux.json), so a full `ShortcutStroke` equality // would miss those collisions. - return lhs.key == rhs.key && sameModifiers(lhs, rhs) + return canonicalConflictKey(lhs.key) == canonicalConflictKey(rhs.key) + && sameModifiers(lhs, rhs) } /// Whether two complete shortcut bindings collide under the app runtime's @@ -101,3 +102,16 @@ private func sameModifiers(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool && lhs.option == rhs.option && lhs.control == rhs.control } + +/// AppKit reports arrow-key events through private-use function-key scalars, +/// while parsed config and built-in shortcuts use visible arrow glyphs. Treat +/// both representations as the same physical key during conflict detection. +private func canonicalConflictKey(_ key: String) -> String { + switch key { + case "\u{F702}": "←" + case "\u{F703}": "→" + case "\u{F700}": "↑" + case "\u{F701}": "↓" + default: key + } +} From f491609a8202e084e13739780df7637bf15f7804 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 06:23:06 -0700 Subject: [PATCH 27/29] test: cover mixed arrow chord prefixes --- ...pDelegateEqualizeSplitsShortcutTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 1ef0305a40a1..2dcbbdd3ffa9 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -467,6 +467,96 @@ final class AppDelegateEqualizeSplitsShortcutTests: XCTestCase { } XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + guard let resizedSplit = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first else { + XCTFail("Expected resized split node") + return + } + XCTAssertGreaterThan(resizedSplit.dividerPosition, 0.5) + XCTAssertEqual(workspace.focusedPanelId, leftPanelId) + } + } + } + + func testSharedArrowChordPrefixCanonicalizesRecordedAndConfiguredRepresentations() { + let recordedFocusShortcut = StoredShortcut( + key: "\u{F703}", + command: false, + shift: false, + option: false, + control: true, + keyCode: 124, + chordKey: "x", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: true + ) + let configuredResizeShortcut = StoredShortcut( + key: "→", + command: false, + shift: false, + option: false, + control: true, + chordKey: "r", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: true + ) + withTemporaryShortcut(action: .focusRight, shortcut: recordedFocusShortcut) { + withTemporaryShortcut(action: .resizeSplitRight, shortcut: configuredResizeShortcut) { + 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 workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected horizontal split setup") + return + } + + workspace.focusPanel(leftPanelId) + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let split = shortcutRoutingSplitNodes( + in: workspace.bonsplitController.treeSnapshot() + ).first, + let splitId = UUID(uuidString: split.id), + let prefixEvent = makeKeyDownEvent( + key: String(UnicodeScalar(NSRightArrowFunctionKey)!), + modifiers: [.control], + keyCode: 124, + windowNumber: window.windowNumber + ), + let resizeEvent = makeKeyDownEvent( + key: "r", + modifiers: [.control], + keyCode: 15, + windowNumber: window.windowNumber + ) else { + XCTFail("Expected split and mixed-representation chord events") + return + } + XCTAssertTrue(workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)) + #if DEBUG XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: resizeEvent)) From cd24d9a1b086e81b83f7945788aec38a5cab05fe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 24 Jul 2026 06:29:17 -0700 Subject: [PATCH 28/29] fix: canonicalize arrow chord prefix identity --- .../CmuxSettings/Values/ShortcutStroke.swift | 20 +++++++++++++++++++ .../Sections/ShortcutConflict.swift | 15 +------------- Sources/AppDelegate.swift | 3 ++- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift index d3f852e4b528..e29e2453f3e9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutStroke.swift @@ -34,4 +34,24 @@ public struct ShortcutStroke: Sendable, Equatable, Hashable, Codable { /// True when at least one of `cmd`, `shift`, `opt`, or `ctrl` is set. public var hasAnyModifier: Bool { command || shift || option || control } + + /// Canonical key token used when comparing stored shortcut strokes. + /// + /// AppKit reports arrow-key events as private-use function-key scalars, + /// while parsed config and built-in shortcuts use visible arrow glyphs. + /// Both representations identify the same physical key. + public var canonicalKeyToken: String { + Self.canonicalKeyToken(for: key) + } + + /// Returns the canonical token for a key string from either storage model. + public static func canonicalKeyToken(for key: String) -> String { + switch key { + case "\u{F702}": "←" + case "\u{F703}": "→" + case "\u{F700}": "↑" + case "\u{F701}": "↓" + default: key + } + } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift index fe3edb8053da..839b4184bf12 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ShortcutConflict.swift @@ -40,7 +40,7 @@ func numberedAwareStrokesConflict( // keystroke can be stored with or without a resolved virtual key code (e.g. // recorded vs. hand-written cmux.json), so a full `ShortcutStroke` equality // would miss those collisions. - return canonicalConflictKey(lhs.key) == canonicalConflictKey(rhs.key) + return lhs.canonicalKeyToken == rhs.canonicalKeyToken && sameModifiers(lhs, rhs) } @@ -102,16 +102,3 @@ private func sameModifiers(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool && lhs.option == rhs.option && lhs.control == rhs.control } - -/// AppKit reports arrow-key events through private-use function-key scalars, -/// while parsed config and built-in shortcuts use visible arrow glyphs. Treat -/// both representations as the same physical key during conflict detection. -private func canonicalConflictKey(_ key: String) -> String { - switch key { - case "\u{F702}": "←" - case "\u{F703}": "→" - case "\u{F700}": "↑" - case "\u{F701}": "↓" - default: key - } -} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 000325755be9..581afadbd8a5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15275,7 +15275,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func configuredShortcutStrokesMatch(_ lhs: ShortcutStroke, _ rhs: ShortcutStroke) -> Bool { - lhs.key == rhs.key + CmuxSettings.ShortcutStroke.canonicalKeyToken(for: lhs.key) + == CmuxSettings.ShortcutStroke.canonicalKeyToken(for: rhs.key) && lhs.command == rhs.command && lhs.shift == rhs.shift && lhs.option == rhs.option From ac77920ad069e3cd2417fee5fa6fb42602ed9f06 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 28 Jul 2026 16:30:31 -0700 Subject: [PATCH 29/29] fix: disallow chords for global hotkey --- Sources/KeyboardShortcutSettings+ActionMetadata.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/KeyboardShortcutSettings+ActionMetadata.swift b/Sources/KeyboardShortcutSettings+ActionMetadata.swift index df5aa3ed2e33..294780f3a02b 100644 --- a/Sources/KeyboardShortcutSettings+ActionMetadata.swift +++ b/Sources/KeyboardShortcutSettings+ActionMetadata.swift @@ -2,7 +2,8 @@ extension KeyboardShortcutSettings.Action { var isSystemWideHotkey: Bool { self == .showHideAllWindows } var allowsChordShortcut: Bool { - self != .fileExplorerOpenSelection + self != .showHideAllWindows + && self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction }