From bcf57dacddb104ba5d78b1da6cecf4b4d309dfc4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:40:24 -0700 Subject: [PATCH 1/2] Pane focus memory and New Pane (Auto Layout) Bumps bonsplit to manaflow-ai/bonsplit#258: directional pane focus returns to the most recently focused pane across an edge (Zellij rule), and closing the focused pane returns to the last used pane. Adds newPaneAutoLayout (default Ctrl+Cmd+N), the cmux-tui Alt-n equivalent: it creates a terminal pane and retiles the workspace in Zellij's default layout through the shared terminal split path. Shortcut, Split menu, and command palette share one handler. Canvas workspaces, the Dock, remote tmux mirrors, and Cloud panes get an ordinary pane to the right. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxConfigSchema.generated.swift | 1 + .../Values/ShortcutAction+Defaults.swift | 1 + .../Values/ShortcutAction+DisplayName.swift | 2 + .../Values/ShortcutAction+Group.swift | 2 +- .../CmuxSettings/Values/ShortcutAction.swift | 1 + Resources/Localizable.xcstrings | 238 ++++++++++++++++++ Sources/AppDelegate+DockShortcutRouting.swift | 2 +- .../AppDelegate+EqualizeSplitsShortcut.swift | 49 ++++ Sources/AppDelegate.swift | 4 +- Sources/ContentView+PaneResizeCommands.swift | 14 ++ ...ntentView+RightSidebarCommandPalette.swift | 2 + Sources/KeyboardShortcutSettings.swift | 8 +- Sources/TabManager+TerminalSplitAction.swift | 27 ++ Sources/Workspace.swift | 12 +- Sources/cmuxApp+EqualizeSplitsMenu.swift | 15 ++ .../references/shortcut-actions.md | 1 + vendor/bonsplit | 2 +- web/data/cmux-shortcuts.ts | 9 + web/data/cmux.schema.json | 1 + 19 files changed, 376 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 0b069c7a0989..5ae0917dab06 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -2049,6 +2049,7 @@ enum CmuxEmbeddedConfigSchema { "focusNextPane", "splitRight", "splitDown", + "newPaneAutoLayout", "toggleSplitZoom", "increaseWorkspaceTerminalFontSize", "decreaseWorkspaceTerminalFontSize", diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index b3f3a90bf7b8..f74930069608 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -133,6 +133,7 @@ extension ShortcutAction { case .focusNextPane: return nil case .splitRight: return ShortcutStroke(key: "d", command: true) case .splitDown: return ShortcutStroke(key: "d", command: true, shift: true) + case .newPaneAutoLayout: return ShortcutStroke(key: "n", command: true, control: true) case .toggleSplitZoom: return ShortcutStroke(key: "\r", command: true, shift: true) case .increaseWorkspaceTerminalFontSize: return ShortcutStroke(key: "=", command: true, control: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index a281a7f36fad..04a981a7cbcd 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -118,6 +118,8 @@ extension ShortcutAction { return String(localized: "shortcut.focusNextPane.label", defaultValue: "Focus Next Pane") case .splitRight: return "Split Right" case .splitDown: return "Split Down" + case .newPaneAutoLayout: + return String(localized: "shortcut.newPaneAutoLayout.label", defaultValue: "New Pane (Auto Layout)") case .toggleSplitZoom: return "Toggle Pane Zoom" case .increaseWorkspaceTerminalFontSize: return String( diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index c185460e0365..a8b85e4eb1bc 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -32,7 +32,7 @@ extension ShortcutAction { .clearScreenKeepScrollback: return .navigation case .focusLeft, .focusRight, .focusUp, .focusDown, - .focusPreviousPane, .focusNextPane, .splitRight, .splitDown, + .focusPreviousPane, .focusNextPane, .splitRight, .splitDown, .newPaneAutoLayout, .toggleSplitZoom, .increaseWorkspaceTerminalFontSize, .decreaseWorkspaceTerminalFontSize, .resetWorkspaceTerminalFontSize, .equalizeSplits, .resizePaneLeft, .resizePaneRight, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 1a38d5180661..74fb99cf9ad2 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -126,6 +126,7 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case focusNextPane case splitRight case splitDown + case newPaneAutoLayout case toggleSplitZoom /// Increases every terminal font size in the selected workspace. case increaseWorkspaceTerminalFontSize diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index bb40a612218a..60a376bba009 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -140287,6 +140287,125 @@ } } }, + "command.newPaneAutoLayout.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لوحة جديدة (تخطيط تلقائي)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi panel (automatski raspored)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt panel (automatisk layout)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Bereich (automatisches Layout)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New Pane (Auto Layout)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo panel (diseño automático)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau panneau (disposition automatique)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuovo pannello (layout automatico)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいペイン(自動レイアウト)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 패널 (자동 레이아웃)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt panel (automatisk oppsett)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy panel (układ automatyczny)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Novo Painel (Layout Automático)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая панель (автоматическая раскладка)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บานหน้าต่างใหม่ (จัดวางอัตโนมัติ)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Bölme (Otomatik Düzen)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Нова панель (автоматичне розташування)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建窗格(自动布局)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增窗格(自動佈局)" + } + } + } + }, "command.equalizeSplits.title": { "extractionState": "manual", "localizations": { @@ -374423,6 +374542,125 @@ } } }, + "shortcut.newPaneAutoLayout.label": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لوحة جديدة (تخطيط تلقائي)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi panel (automatski raspored)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt panel (automatisk layout)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Bereich (automatisches Layout)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New Pane (Auto Layout)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo panel (diseño automático)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau panneau (disposition automatique)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuovo pannello (layout automatico)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいペイン(自動レイアウト)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 패널 (자동 레이아웃)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt panel (automatisk oppsett)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowy panel (układ automatyczny)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Novo Painel (Layout Automático)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая панель (автоматическая раскладка)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บานหน้าต่างใหม่ (จัดวางอัตโนมัติ)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Bölme (Otomatik Düzen)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Нова панель (автоматичне розташування)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建窗格(自动布局)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增窗格(自動佈局)" + } + } + } + }, "shortcut.focusPreviousPane.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+DockShortcutRouting.swift b/Sources/AppDelegate+DockShortcutRouting.swift index 4deddcd95eca..be2c4f12a328 100644 --- a/Sources/AppDelegate+DockShortcutRouting.swift +++ b/Sources/AppDelegate+DockShortcutRouting.swift @@ -55,7 +55,7 @@ extension KeyboardShortcutSettings.Action { .clearScreenKeepScrollback, .focusLeft, .focusRight, .focusUp, .focusDown, .focusPreviousPane, .focusNextPane, - .splitRight, .splitDown, .toggleSplitZoom, + .splitRight, .splitDown, .newPaneAutoLayout, .toggleSplitZoom, .resizePaneLeft, .resizePaneRight, .resizePaneUp, .resizePaneDown, .equalizeSplits, .splitBrowserRight, .splitBrowserDown, diff --git a/Sources/AppDelegate+EqualizeSplitsShortcut.swift b/Sources/AppDelegate+EqualizeSplitsShortcut.swift index 8714ba2f3df3..61ded014921e 100644 --- a/Sources/AppDelegate+EqualizeSplitsShortcut.swift +++ b/Sources/AppDelegate+EqualizeSplitsShortcut.swift @@ -35,6 +35,39 @@ extension AppDelegate { #endif } + /// New Pane (Auto Layout), cmux-tui's Alt-n: adds a terminal pane and + /// retiles the workspace in Zellij's default layout. Canvas workspaces + /// have no tiling, so they open the pane to the right. Shortcut and + /// command palette both call this method. + @discardableResult + func performAutoLayoutPaneShortcut(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + let routedManager = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) ?? tabManager + let workspace = terminalContext.flatMap { context in + context.tabManager.tabs.first { $0.id == context.workspaceId } + } ?? routedManager?.selectedWorkspace + let didCreate: Bool + if let workspace, workspace.layoutMode == .canvas { + didCreate = workspace.openNewCanvasPane( + type: .terminal, + focus: true, + direction: SplitDirection.right.canvasDirection + ) != nil + } else if let terminalContext { + didCreate = terminalContext.tabManager.createAutoLayoutPaneOutcome( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId + ).isAccepted + } else { + didCreate = routedManager?.createAutoLayoutPaneOutcome().isAccepted ?? false + } +#if DEBUG + cmuxDebugLog("shortcut.action name=newPaneAutoLayout result=\(didCreate ? 1 : 0)") +#endif + return didCreate + } + /// Runs one pane-resize step against the focused split tree. Menu actions, /// command-palette commands, and key events all call this method so the /// focused Dock and main workspace share the same mutation path. @@ -84,6 +117,22 @@ extension AppDelegate { } func handlePaneSizingShortcut(event: NSEvent, equalize: Bool) -> Bool { + if matchConfiguredShortcut(event: event, action: .newPaneAutoLayout) { + // A focused Dock has no auto layout; it gets its ordinary split right. + if routeSplitToFocusedDock( + kind: .terminal, + direction: .right, + action: .newPaneAutoLayout, + preferredWindow: event.window + ) { + return true + } + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } + _ = performAutoLayoutPaneShortcut(preferredWindow: event.window ?? shortcutRoutingActiveWindow) + return true + } if equalize { if performFocusedDockShortcut( .equalizeSplits, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 35fc9b3e75c6..a0c05066b0ba 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7539,7 +7539,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context.tabManager } - private struct FocusedTerminalShortcutContext { + struct FocusedTerminalShortcutContext { let tabManager: TabManager let workspaceId: UUID let panelId: UUID @@ -7587,7 +7587,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } - private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { + func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { let targetWindow = preferredWindow ?? shortcutRoutingActiveWindow let responder = shortcutRoutingFirstResponder(preferredWindow: targetWindow) guard let ghosttyView = responder.cmuxStrictOwningGhosttyView(), diff --git a/Sources/ContentView+PaneResizeCommands.swift b/Sources/ContentView+PaneResizeCommands.swift index c5184c3eb811..0ba775d36f5c 100644 --- a/Sources/ContentView+PaneResizeCommands.swift +++ b/Sources/ContentView+PaneResizeCommands.swift @@ -16,6 +16,15 @@ extension ContentView { when: { $0.bool(CommandPaletteContextKeys.workspaceHasSplits) } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newPaneAutoLayout", + title: { _ in String(localized: "command.newPaneAutoLayout.title", defaultValue: "New Pane (Auto Layout)") }, + subtitle: subtitle, + keywords: ["pane", "new", "split", "auto", "layout", "tile", "zellij", "grid"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) for (commandId, action, directionKeyword) in [ ("palette.resizePaneLeft", KeyboardShortcutSettings.Action.resizePaneLeft, "left"), ("palette.resizePaneRight", .resizePaneRight, "right"), @@ -40,6 +49,11 @@ extension ContentView { _ registry: inout CommandPaletteHandlerRegistry, preferredWindow: @escaping () -> NSWindow? ) { + registry.register(commandId: "palette.newPaneAutoLayout") { + if AppDelegate.shared?.performAutoLayoutPaneShortcut(preferredWindow: preferredWindow()) != true { + NSSound.beep() + } + } for (commandId, direction) in [ ("palette.resizePaneLeft", ResizeDirection.left), ("palette.resizePaneRight", .right), diff --git a/Sources/ContentView+RightSidebarCommandPalette.swift b/Sources/ContentView+RightSidebarCommandPalette.swift index cc3615c08336..3fe2898d7dad 100644 --- a/Sources/ContentView+RightSidebarCommandPalette.swift +++ b/Sources/ContentView+RightSidebarCommandPalette.swift @@ -112,6 +112,8 @@ extension ContentView { return .toggleSplitZoom case "palette.equalizeSplits": return .equalizeSplits + case "palette.newPaneAutoLayout": + return .newPaneAutoLayout case "palette.resizePaneLeft": return .resizePaneLeft case "palette.resizePaneRight": diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index fb2f208c7c8d..3cb84c70497d 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -163,7 +163,7 @@ enum KeyboardShortcutSettings { case focusPreviousPane case focusNextPane case splitRight - case splitDown, toggleSplitZoom + case splitDown, toggleSplitZoom, newPaneAutoLayout case increaseWorkspaceTerminalFontSize case decreaseWorkspaceTerminalFontSize case resetWorkspaceTerminalFontSize @@ -326,6 +326,7 @@ enum KeyboardShortcutSettings { case .focusNextPane: return String(localized: "shortcut.focusNextPane.label", defaultValue: "Focus Next Pane") case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right") case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down") + case .newPaneAutoLayout: return String(localized: "shortcut.newPaneAutoLayout.label", defaultValue: "New Pane (Auto Layout)") case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom") case .increaseWorkspaceTerminalFontSize: return String( @@ -555,13 +556,12 @@ enum KeyboardShortcutSettings { // cycles panes on the terminal-config keys when Focus Back/Forward do // not claim them; these entries exist so pane cycling stays rebindable // now that ⌘[ / ⌘] reach global focus history. - case .focusPreviousPane: - return .unbound - case .focusNextPane: + case .focusPreviousPane, .focusNextPane: return .unbound case .splitRight: return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) + case .newPaneAutoLayout: return StoredShortcut(key: "n", command: true, shift: false, option: false, control: true) case .toggleSplitZoom: return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false) case .increaseWorkspaceTerminalFontSize: return StoredShortcut(key: "=", command: true, shift: false, option: false, control: true) diff --git a/Sources/TabManager+TerminalSplitAction.swift b/Sources/TabManager+TerminalSplitAction.swift index 26e294d34768..896308ab8ec5 100644 --- a/Sources/TabManager+TerminalSplitAction.swift +++ b/Sources/TabManager+TerminalSplitAction.swift @@ -33,4 +33,31 @@ extension TabManager { } return outcome } + + /// Adds a terminal pane from the focused panel and arranges the + /// workspace in Zellij's default tiling, the cmux-tui Alt-n behavior. + /// Remote tmux mirrors and Cloud panes keep their own layout owner, so + /// they receive an ordinary split right. + func createAutoLayoutPaneOutcome( + tabId: UUID, + surfaceId: UUID, + focus: Bool = true + ) -> TerminalPanelCreationOutcome { + guard let workspace = tabs.first(where: { $0.id == tabId }), + workspace.panels[surfaceId] != nil else { return .failed } + sentryBreadcrumb("split.create", data: ["direction": "autoLayout"]) + return workspace.newTerminalSplitOutcome( + from: surfaceId, + orientation: .horizontal, + focus: focus, + autoLayout: true + ) + } + + func createAutoLayoutPaneOutcome() -> TerminalPanelCreationOutcome { + guard let selectedTabId, + let workspace = tabs.first(where: { $0.id == selectedTabId }), + let panelID = workspace.focusedPanelId else { return .failed } + return createAutoLayoutPaneOutcome(tabId: selectedTabId, surfaceId: panelID) + } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2d8fa55404eb..adb6126c8fcc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8853,7 +8853,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos initialDividerPosition: CGFloat? = nil, remotePTYSessionID: String? = nil, suppressWorkspaceRemoteStartupCommand: Bool = false, - allowTextBoxFocusDefault: Bool = true + allowTextBoxFocusDefault: Bool = true, autoLayout: Bool = false ) -> TerminalPanelCreationOutcome { guard !isRetiredFromOwningTabManager else { return .failed } // In a remote tmux mirror workspace a split means "split the mirrored @@ -8898,7 +8898,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos initialDividerPosition: initialDividerPosition, remotePTYSessionID: remotePTYSessionID, suppressWorkspaceRemoteStartupCommand: suppressWorkspaceRemoteStartupCommand, - allowTextBoxFocusDefault: allowTextBoxFocusDefault + allowTextBoxFocusDefault: allowTextBoxFocusDefault, autoLayout: autoLayout ) else { return .failed } return .created(panel) } @@ -8915,7 +8915,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos initialDividerPosition: CGFloat?, remotePTYSessionID: String?, suppressWorkspaceRemoteStartupCommand: Bool, - allowTextBoxFocusDefault: Bool + allowTextBoxFocusDefault: Bool, autoLayout: Bool ) -> TerminalPanel? { #if DEBUG let splitTimingStart = ProcessInfo.processInfo.systemUptime @@ -9041,7 +9041,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos // Create the split with the new tab already present in the new pane. isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else { + guard let newPaneId = autoLayout ? bonsplitController.addPaneWithAutoLayout(from: paneId, withTab: newTab) : bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) remotePTYSessionIDsByPanelId.removeValue(forKey: newPanel.id) @@ -9052,8 +9052,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } return nil } - applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: paneId, newPaneId: newPaneId) - publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "terminal_split", focused: focus) + if !autoLayout { applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: paneId, newPaneId: newPaneId) } + publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: autoLayout ? "terminal_auto_layout" : "terminal_split", focused: focus) #if DEBUG cmuxDebugLog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") diff --git a/Sources/cmuxApp+EqualizeSplitsMenu.swift b/Sources/cmuxApp+EqualizeSplitsMenu.swift index 5f470d2a624a..0c23e99c095a 100644 --- a/Sources/cmuxApp+EqualizeSplitsMenu.swift +++ b/Sources/cmuxApp+EqualizeSplitsMenu.swift @@ -24,6 +24,21 @@ extension cmuxApp { @ViewBuilder func paneSizingCommandButtons() -> some View { + splitCommandButton( + title: KeyboardShortcutSettings.Action.newPaneAutoLayout.label, + shortcut: menuShortcut(for: .newPaneAutoLayout) + ) { + guard let appDelegate = AppDelegate.shared else { return } + if appDelegate.routeSplitToFocusedDock( + kind: .terminal, + direction: .right, + action: .newPaneAutoLayout, + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) { + return + } + _ = appDelegate.performAutoLayoutPaneShortcut() + } equalizeSplitsCommandButton() splitCommandButton( title: KeyboardShortcutSettings.Action.resizePaneLeft.label, diff --git a/skills/cmux-settings/references/shortcut-actions.md b/skills/cmux-settings/references/shortcut-actions.md index e85a1c4e5245..2c32b02f9028 100644 --- a/skills/cmux-settings/references/shortcut-actions.md +++ b/skills/cmux-settings/references/shortcut-actions.md @@ -94,6 +94,7 @@ Values for `shortcuts.bindings.`: - `shortcuts.bindings.simulatorToggleAppearance` - `shortcuts.bindings.simulatorToggleSoftwareKeyboard` - `shortcuts.bindings.splitDown` +- `shortcuts.bindings.newPaneAutoLayout` - `shortcuts.bindings.splitRight` - `shortcuts.bindings.toggleSplitZoom` - `shortcuts.bindings.toggleTerminalCopyMode` diff --git a/vendor/bonsplit b/vendor/bonsplit index fce65b5bd2d9..e62c7676b067 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit fce65b5bd2d99acdb5af3eb474a4c9b9183b37c1 +Subproject commit e62c7676b067fc716026230384ece644710811b6 diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 846b5993ec8d..280502f92a48 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -408,6 +408,15 @@ export const shortcutCategories: ShortcutCategory[] = [ }, { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, + { + id: "newPaneAutoLayout", + combos: [["⌃", "⌘", "N"]], + description: { en: "New pane (auto layout)", ja: "新しいペイン(自動レイアウト)" }, + note: { + en: "retiles all panes like Zellij and cmux-tui Alt-n: the right column fills to four panes before a new column opens", + ja: "Zellij や cmux-tui の Alt-n と同様に全ペインを再配置します。右の列が 4 ペインになると新しい列が開きます", + }, + }, { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, { id: "splitBrowserDown", combos: [["⌥", "⌘", "⇧", "D"]], description: { en: "Split browser down", ja: "下にブラウザ分割" } }, { id: "toggleSplitZoom", combos: [["⌘", "⇧", "↩"]], description: { en: "Toggle pane zoom", ja: "ペインズームを切り替え" } }, diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 112255827756..5365d37a7f2d 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -2039,6 +2039,7 @@ "focusNextPane", "splitRight", "splitDown", + "newPaneAutoLayout", "toggleSplitZoom", "increaseWorkspaceTerminalFontSize", "decreaseWorkspaceTerminalFontSize", From d2700ba9e015716a59878539f5c75438ed99a895 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:12:40 -0700 Subject: [PATCH 2/2] Exempt the generated config schema from the Swift length budget Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/swift_file_length_budget.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/swift_file_length_budget.py b/scripts/swift_file_length_budget.py index 299a711c63a8..4d7798bf00a2 100644 --- a/scripts/swift_file_length_budget.py +++ b/scripts/swift_file_length_budget.py @@ -13,6 +13,9 @@ def git(*args): # quicktype emits one monolithic Codable model file for this wire contract; # schema additions must change its generated line count atomically. Path("Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/ControlPlane/V2WireModels.swift"), + # generate-cmux-config-schema.py embeds web/data/cmux.schema.json line for + # line, so every new schema key or enum value adds a line here. + Path("Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift"), }