Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ccf8aed
Add configurable new workspace command
lawrencecchen Apr 22, 2026
b4448d1
Add configurable surface tab bar buttons
lawrencecchen Apr 22, 2026
1dc98b0
Merge remote-tracking branch 'origin/main' into feat-custom-new-command
lawrencecchen Apr 22, 2026
047c077
Support SVG action button icons
lawrencecchen Apr 22, 2026
de7a554
Address configurable action button feedback
lawrencecchen Apr 22, 2026
cd60241
Update Bonsplit SVG icon handling
lawrencecchen Apr 22, 2026
98bdab7
Avoid duplicate fallback workspace
lawrencecchen Apr 22, 2026
4cd0415
Run configured button commands in new terminals
lawrencecchen Apr 22, 2026
107d940
Resolve tab bar button actions after config merge
lawrencecchen Apr 22, 2026
4a5b928
Update Bonsplit icon cache bound
lawrencecchen Apr 22, 2026
b6aa82d
Fix configurable button review issues
lawrencecchen Apr 22, 2026
f93934e
Update Bonsplit split button gating
lawrencecchen Apr 22, 2026
71fdde2
Merge remote-tracking branch 'origin/main' into feat-custom-new-command
lawrencecchen Apr 22, 2026
131925f
Fix configurable button review issues
lawrencecchen Apr 22, 2026
6398605
Fix configurable action review feedback
lawrencecchen Apr 22, 2026
dc8bf8d
Update Bonsplit split button duplicate handling
lawrencecchen Apr 22, 2026
d01ce22
Fix config command execution review feedback
lawrencecchen Apr 22, 2026
d65b00e
Update Bonsplit split button pressed state
lawrencecchen Apr 22, 2026
2781fa6
Update Bonsplit brightness-only pressed state
lawrencecchen Apr 22, 2026
17ab8b4
Merge remote-tracking branch 'origin/main' into feat-custom-new-command
lawrencecchen Apr 22, 2026
b392d7e
Unify cmux actions across commands and buttons
lawrencecchen Apr 22, 2026
03c0eb6
Polish custom action config and trust flow
lawrencecchen Apr 23, 2026
9aa1432
Fix nested dogfood action icon config
lawrencecchen Apr 23, 2026
b643c55
Show cmux.json schema warnings
lawrencecchen Apr 23, 2026
8072c47
Add emoji scale for custom action icons
lawrencecchen Apr 23, 2026
6683bf6
Merge remote-tracking branch 'origin/main' into feat-custom-new-command
lawrencecchen Apr 23, 2026
8c0e934
Address custom action review feedback
lawrencecchen Apr 23, 2026
e20e59c
Fix new workspace action review issues
lawrencecchen Apr 23, 2026
e7e2134
Use tab bar config source for button trust
lawrencecchen Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 91 additions & 28 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2223,19 +2223,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let tabManager: TabManager
let sidebarState: SidebarState
let sidebarSelectionState: SidebarSelectionState
var cmuxConfigStore: CmuxConfigStore?
weak var window: NSWindow?

init(
windowId: UUID,
tabManager: TabManager,
sidebarState: SidebarState,
sidebarSelectionState: SidebarSelectionState,
cmuxConfigStore: CmuxConfigStore?,
window: NSWindow?
) {
self.windowId = windowId
self.tabManager = tabManager
self.sidebarState = sidebarState
self.sidebarSelectionState = sidebarSelectionState
self.cmuxConfigStore = cmuxConfigStore
self.window = window
}
}
Expand Down Expand Up @@ -4825,7 +4828,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
windowId: UUID,
tabManager: TabManager,
sidebarState: SidebarState,
sidebarSelectionState: SidebarSelectionState
sidebarSelectionState: SidebarSelectionState,
cmuxConfigStore: CmuxConfigStore? = nil
) {
tabManager.window = window

Expand All @@ -4835,15 +4839,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#endif
if let existing = mainWindowContexts[key] {
existing.window = window
existing.cmuxConfigStore = cmuxConfigStore
} else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) {
existing.window = window
existing.cmuxConfigStore = cmuxConfigStore
reindexMainWindowContextIfNeeded(existing, for: window)
} else {
mainWindowContexts[key] = MainWindowContext(
windowId: windowId,
tabManager: tabManager,
sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState,
cmuxConfigStore: cmuxConfigStore,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
window: window
)
NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -6583,6 +6590,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
_ = createMainWindow()
}

@discardableResult
func performNewWorkspaceAction(
tabManager preferredTabManager: TabManager? = nil,
event: NSEvent? = nil,
debugSource: String = "newWorkspace"
) -> Bool {
let preferredContext = preferredTabManager.flatMap { mainWindowContext(for: $0) }
if mainWindowContexts.isEmpty && preferredContext == nil {
#if DEBUG
logWorkspaceCreationRouting(
phase: "fallback_new_window",
source: debugSource,
reason: "no_main_windows",
event: event,
chosenContext: nil
)
#endif
openNewMainWindow(nil)
return true
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
}

let context = preferredContext
?? preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource)

if let context,
executeConfiguredNewWorkspaceCommandIfAvailable(in: context, debugSource: debugSource) {
return true
}

if let preferredTabManager, preferredContext != nil {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
preferredTabManager.addWorkspace()
return true
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

if addWorkspaceInPreferredMainWindow(event: event, debugSource: debugSource) == nil {
#if DEBUG
logWorkspaceCreationRouting(
phase: "fallback_new_window",
source: debugSource,
reason: "workspace_creation_returned_nil",
event: event,
chosenContext: nil
)
#endif
openNewMainWindow(nil)
}
return true
}

private func mainWindowContext(for tabManager: TabManager) -> MainWindowContext? {
mainWindowContexts.values.first(where: { $0.tabManager === tabManager })
}

private func executeConfiguredNewWorkspaceCommandIfAvailable(
in context: MainWindowContext,
debugSource: String
) -> Bool {
guard let cmuxConfigStore = context.cmuxConfigStore,
let configured = cmuxConfigStore.resolvedNewWorkspaceCommand() else {
return false
}
let rawCwd = context.tabManager.selectedWorkspace?.currentDirectory
let baseCwd = (rawCwd?.isEmpty == false) ? rawCwd!
: FileManager.default.homeDirectoryForCurrentUser.path
#if DEBUG
dlog(
"newWorkspace.configCommand source=\(debugSource) " +
"command=\(configured.command.name) windowId=\(String(context.windowId.uuidString.prefix(8)))"
)
#endif
CmuxConfigExecutor.execute(
command: configured.command,
tabManager: context.tabManager,
baseCwd: baseCwd,
configSourcePath: configured.sourcePath,
globalConfigPath: cmuxConfigStore.globalConfigPath
)
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
}

/// Shows the "Open Folder" panel and creates a workspace for the selected directory.
/// Called from both the SwiftUI menu and `handleCustomShortcut`.
func showOpenFolderPanel() {
Expand Down Expand Up @@ -7225,7 +7312,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
windowId: windowId,
tabManager: tabManager,
sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState
sidebarSelectionState: sidebarSelectionState,
cmuxConfigStore: cmuxConfigStore
)
installFileDropOverlay(on: window, tabManager: tabManager)
if TerminalController.shouldSuppressSocketCommandActivation() {
Expand Down Expand Up @@ -11071,32 +11159,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#if DEBUG
dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))")
#endif
// Cmd+N semantics:
// - If there are no main windows, create a new window.
// - Otherwise, create a new workspace in the active window.
if mainWindowContexts.isEmpty {
#if DEBUG
logWorkspaceCreationRouting(
phase: "fallback_new_window",
source: "shortcut.cmdN",
reason: "no_main_windows",
event: event,
chosenContext: nil
)
#endif
openNewMainWindow(nil)
} else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil {
#if DEBUG
logWorkspaceCreationRouting(
phase: "fallback_new_window",
source: "shortcut.cmdN",
reason: "workspace_creation_returned_nil",
event: event,
chosenContext: nil
)
#endif
openNewMainWindow(nil)
}
performNewWorkspaceAction(event: event, debugSource: "shortcut.cmdN")
return true
}

Expand Down
58 changes: 58 additions & 0 deletions Sources/CmuxConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,37 @@ import Combine
import Foundation

struct CmuxConfigFile: Codable, Sendable {
var newWorkspaceCommand: String?
var commands: [CmuxCommandDefinition]

private enum CodingKeys: String, CodingKey {
case newWorkspaceCommand
case commands
}

init(newWorkspaceCommand: String? = nil, commands: [CmuxCommandDefinition]) {
self.newWorkspaceCommand = newWorkspaceCommand
self.commands = commands
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let rawNewWorkspaceCommand = try container.decodeIfPresent(String.self, forKey: .newWorkspaceCommand) {
let trimmed = rawNewWorkspaceCommand.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath + [CodingKeys.newWorkspaceCommand],
debugDescription: "newWorkspaceCommand must not be blank"
)
)
}
newWorkspaceCommand = trimmed
} else {
newWorkspaceCommand = nil
}
commands = try container.decode([CmuxCommandDefinition].self, forKey: .commands)
}
}

struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
Expand Down Expand Up @@ -256,9 +286,15 @@ enum CmuxSurfaceType: String, Codable, Sendable {
case browser
}

struct CmuxResolvedCommand: Sendable {
let command: CmuxCommandDefinition
let sourcePath: String?
}

@MainActor
final class CmuxConfigStore: ObservableObject {
@Published private(set) var loadedCommands: [CmuxCommandDefinition] = []
@Published private(set) var newWorkspaceCommandName: String?
@Published private(set) var configRevision: UInt64 = 0

/// Which config file each command came from, keyed by command id.
Expand Down Expand Up @@ -353,10 +389,14 @@ final class CmuxConfigStore: ObservableObject {
var commands: [CmuxCommandDefinition] = []
var seenNames = Set<String>()
var sourcePaths: [String: String] = [:]
var configuredNewWorkspaceCommandName: String?

// Local config takes precedence
if let localPath = localConfigPath {
if let localConfig = parseConfig(at: localPath) {
if let newWorkspaceCommand = localConfig.newWorkspaceCommand {
configuredNewWorkspaceCommandName = newWorkspaceCommand
}
for command in localConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
Expand All @@ -369,6 +409,10 @@ final class CmuxConfigStore: ObservableObject {

// Global config fills in the rest
if let globalConfig = parseConfig(at: globalConfigPath) {
if configuredNewWorkspaceCommandName == nil,
let newWorkspaceCommand = globalConfig.newWorkspaceCommand {
configuredNewWorkspaceCommandName = newWorkspaceCommand
}
for command in globalConfig.commands {
if !seenNames.contains(command.name) {
commands.append(command)
Expand All @@ -380,9 +424,23 @@ final class CmuxConfigStore: ObservableObject {

loadedCommands = commands
commandSourcePaths = sourcePaths
newWorkspaceCommandName = configuredNewWorkspaceCommandName
configRevision &+= 1
}

func resolvedNewWorkspaceCommand() -> CmuxResolvedCommand? {
guard let commandName = newWorkspaceCommandName else { return nil }
guard let command = loadedCommands.first(where: { $0.name == commandName }) else {
NSLog("[CmuxConfig] newWorkspaceCommand '%@' does not match any loaded command", commandName)
return nil
}
guard command.workspace != nil else {
NSLog("[CmuxConfig] newWorkspaceCommand '%@' must reference a workspace command", commandName)
return nil
}
Comment thread
lawrencecchen marked this conversation as resolved.
Resolved
return CmuxResolvedCommand(command: command, sourcePath: commandSourcePaths[command.id])
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// MARK: - Parsing

private func parseConfig(at path: String) -> CmuxConfigFile? {
Expand Down
15 changes: 12 additions & 3 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3073,7 +3073,12 @@ struct ContentView: View {
anchorView: fullscreenControlsViewModel.notificationsAnchorView
)
},
onNewTab: { tabManager.addTab() },
onNewTab: {
AppDelegate.shared?.performNewWorkspaceAction(
tabManager: tabManager,
debugSource: "titlebar.fullscreenNewWorkspace"
)
},
visibilityMode: .alwaysVisible
)
}
Expand Down Expand Up @@ -3978,7 +3983,8 @@ struct ContentView: View {
windowId: windowId,
tabManager: tabManager,
sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState
sidebarSelectionState: sidebarSelectionState,
cmuxConfigStore: cmuxConfigStore
)
installFileDropOverlayWhenReady(on: window, tabManager: tabManager)
}))
Expand Down Expand Up @@ -7569,7 +7575,10 @@ struct ContentView: View {

private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) {
registry.register(commandId: "palette.newWorkspace") {
tabManager.addWorkspace()
AppDelegate.shared?.performNewWorkspaceAction(
tabManager: tabManager,
debugSource: "palette.newWorkspace"
)
}
registry.register(commandId: "palette.openFolder") {
// Defer so the command palette dismisses before the modal sheet appears.
Expand Down
6 changes: 4 additions & 2 deletions Sources/Update/UpdateTitlebarAccessory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,9 @@ struct HiddenTitlebarSidebarControlsView: View {
anchorView: viewModel.notificationsAnchorView
)
},
onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() },
onNewTab: {
AppDelegate.shared?.performNewWorkspaceAction(debugSource: "titlebar.hiddenNewWorkspace")
},
visibilityMode: .onHover
)
.frame(width: hostWidth, height: hostHeight, alignment: .leading)
Expand Down Expand Up @@ -791,7 +793,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
self.notificationStore = notificationStore
let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() }
let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) }
let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() }
let newTab = { _ = AppDelegate.shared?.performNewWorkspaceAction(debugSource: "titlebar.accessoryNewWorkspace") }
hostingView = NonDraggableHostingView(
rootView: TitlebarControlsView(
notificationStore: notificationStore,
Expand Down
14 changes: 5 additions & 9 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -584,16 +584,12 @@ struct cmuxApp: App {

splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: menuShortcut(for: .newTab)) {
if let appDelegate = AppDelegate.shared {
if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil {
#if DEBUG
FocusLogStore.shared.append(
"cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil"
)
#endif
appDelegate.openNewMainWindow(nil)
}
appDelegate.performNewWorkspaceAction(
tabManager: activeTabManager,
debugSource: "menu.newWorkspace"
)
} else {
activeTabManager.addTab()
activeTabManager.addWorkspace()
}
}

Expand Down
Loading
Loading