Make Cmd+W close the window when it closes the last terminal

Previously, Cmd+W on the last surface kept the workspace alive with a
replacement shell, unless the user toggled a hidden setting. This was
confusing—users expect Cmd+W to close the window when there's nothing
left.

Now Cmd+W (and the tab-strip X button) always close the workspace when
they close its last surface, and close the window when that was the last
workspace. Internal/programmatic closes (e.g. process exit, panel moves)
still spawn a replacement shell so the workspace stays alive.

Key changes:
- Track explicit user close gestures via markExplicitClose / onTabCloseRequest
- Remove the LastSurfaceCloseShortcutSettings toggle (now always-on)
- Use window.performClose for last-workspace window close
- Update tests to match the new behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
austinpower1258 2026-03-13 12:43:24 -07:00
parent 44c1a36d14
commit 9bfa3b9143
6 changed files with 129 additions and 180 deletions

View file

@ -51,18 +51,6 @@ enum WorkspaceAutoReorderSettings {
}
}
enum LastSurfaceCloseShortcutSettings {
static let key = "closeWorkspaceOnLastSurfaceShortcut"
static let defaultValue = false
static func closesWorkspace(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: key) == nil {
return defaultValue
}
return defaults.bool(forKey: key)
}
}
enum SidebarBranchLayoutSettings {
static let key = "sidebarBranchVerticalLayout"
static let defaultVerticalLayout = true
@ -1607,15 +1595,17 @@ class TabManager: ObservableObject {
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
if let closeButton = alert.buttons.first {
// Keep Return/Enter bound to the primary destructive action for all close prompts.
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
}
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
// conflicting with app-level Cmd+D (split right) during normal usage.
if acceptCmdD, let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "d"
closeButton.keyEquivalentModifierMask = [.command]
// Keep Return/Enter behavior by explicitly setting the default button cell.
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
}
return alert.runModal() == .alertFirstButtonReturn
@ -1741,7 +1731,11 @@ class TabManager: ObservableObject {
}
if tabs.count <= 1 {
// Last workspace in this window: close the window (Cmd+Shift+W behavior).
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
if let window {
window.performClose(nil)
} else {
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
}
} else {
closeWorkspace(workspace)
}
@ -1771,14 +1765,16 @@ class TabManager: ObservableObject {
dlog(
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " +
"closeWorkspaceOnLastSurfaceSetting=\(LastSurfaceCloseShortcutSettings.closesWorkspace() ? 1 : 0)"
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)"
)
#endif
// Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close
// button, including shared confirmation, setting-controlled last-surface behavior, and
// replacement-panel flow.
// button, including shared confirmation, last-surface workspace/window-close behavior,
// and the usual replacement-panel flow when the close does not collapse the workspace.
if let surfaceId = tab.surfaceIdFromPanelId(panelId) {
tab.markExplicitClose(surfaceId: surfaceId)
}
let closed = tab.closePanel(panelId)
#if DEBUG
dlog(

View file

@ -1182,6 +1182,9 @@ final class Workspace: Identifiable, ObservableObject {
bonsplitController.onExternalTabDrop = { [weak self] request in
self?.handleExternalTabDrop(request) ?? false
}
bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in
self?.markExplicitClose(surfaceId: tabId)
}
// Set ourselves as delegate
bonsplitController.delegate = self
@ -1228,6 +1231,10 @@ final class Workspace: Identifiable, ObservableObject {
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
private var pendingCloseConfirmTabIds: Set<TabID> = []
/// Tab IDs whose next close attempt came from an explicit user close gesture
/// (Cmd+W or the tab-strip X button), rather than an internal close/move flow.
private var explicitUserCloseTabIds: Set<TabID> = []
/// Deterministic tab selection to apply after a tab closes.
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
private var postCloseSelectTabId: [TabID: TabID] = [:]
@ -1294,6 +1301,10 @@ final class Workspace: Identifiable, ObservableObject {
surfaceIdToPanelId[surfaceId]
}
func markExplicitClose(surfaceId: TabID) {
explicitUserCloseTabIds.insert(surfaceId)
}
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
surfaceIdToPanelId.first { $0.value == panelId }?.key
}
@ -4118,8 +4129,7 @@ extension Workspace: BonsplitDelegate {
@MainActor
private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool {
let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager
guard LastSurfaceCloseShortcutSettings.closesWorkspace(),
panels.count <= 1,
guard panels.count <= 1,
panelIdFromSurfaceId(tabId) != nil,
let manager,
manager.tabs.contains(where: { $0.id == id }) else {
@ -4137,6 +4147,10 @@ extension Workspace: BonsplitDelegate {
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
if let closeButton = alert.buttons.first {
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
}
// Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
return await withCheckedContinuation { continuation in
@ -4530,6 +4544,8 @@ extension Workspace: BonsplitDelegate {
}
}
let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil
if forceCloseTabIds.contains(tab.id) {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection()
@ -4543,7 +4559,7 @@ extension Workspace: BonsplitDelegate {
return false
}
if shouldCloseWorkspaceOnLastSurface(for: tab.id) {
if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
owningTabManager?.closeWorkspaceWithConfirmation(self)
return false

View file

@ -452,10 +452,9 @@ struct cmuxApp: App {
Divider()
// Terminal semantics:
// Cmd+W closes the focused tab/surface (with confirmation if needed) and keeps
// the workspace open by default. Cmd+Shift+W is the explicit workspace-close
// action, unless the user opts into closing the workspace when its last surface
// is closed.
// Cmd+W closes the focused tab/surface (with confirmation if needed). When that
// was the last surface in the workspace, cmux removes the workspace and closes
// the window if it was also the last workspace.
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
closePanelOrWindow()
}
@ -3077,8 +3076,6 @@ struct SettingsView: View {
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(LastSurfaceCloseShortcutSettings.key)
private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey)
private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
@ -3124,19 +3121,6 @@ struct SettingsView: View {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
}
private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String {
if closeWorkspaceOnLastSurfaceShortcut {
return String(
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn",
defaultValue: "Closing the last surface also closes its workspace."
)
}
return String(
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff",
defaultValue: "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly."
)
}
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
@ -3505,17 +3489,6 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Closing Last Surface Closes Workspace"),
subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle
) {
Toggle("", isOn: $closeWorkspaceOnLastSurfaceShortcut)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"),
subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.")
@ -4485,7 +4458,6 @@ struct SettingsView: View {
ShortcutHintDebugSettings.resetVisibilityDefaults()
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage

View file

@ -529,6 +529,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window")
}
func testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let targetWindow = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId) else {
XCTFail("Expected test window and manager")
return
}
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.tabs[0].panels.count, 1)
guard let event = makeKeyDownEvent(
key: "w",
modifiers: [.command],
keyCode: 13,
windowNumber: targetWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+W event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertNil(
self.window(withId: windowId),
"Cmd+W on the last surface in the last workspace should close the window"
)
}
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")

View file

@ -4802,43 +4802,6 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase {
}
}
final class LastSurfaceCloseShortcutSettingsTests: XCTestCase {
func testDefaultKeepsWorkspaceOpen() {
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
}
func testStoredTrueClosesWorkspace() {
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
}
func testStoredFalseKeepsWorkspaceOpen() {
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key)
XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
}
}
final class SidebarBranchLayoutSettingsTests: XCTestCase {
func testDefaultUsesVerticalLayout() {
let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
@ -5397,31 +5360,61 @@ final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
@MainActor
final class TabManagerCloseCurrentPanelTests: XCTestCase {
func testCloseCurrentPanelKeepsWorkspaceOpenWhenItOwnsTheLastSurface() {
func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace and focused panel")
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
let initialWorkspaceId = workspace.id
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(workspace.panels.count, 1)
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
manager.closeCurrentPanelWithConfirmation()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.count, 1, "Closing the last surface should not remove the workspace")
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
XCTAssertNil(workspace.panels[initialPanelId], "Expected the original surface to be closed")
XCTAssertEqual(workspace.panels.count, 1, "Expected the workspace to stay alive with a replacement surface")
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
XCTAssertNil(secondWorkspace.panels[secondPanelId])
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
func testClosePanelButtonKeepsWorkspaceOpenWhenItOwnsTheLastSurface() {
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
XCTFail("Expected bonsplit surface ID for focused panel")
return
}
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
XCTAssertNil(secondWorkspace.panels[secondPanelId])
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
@ -5437,85 +5430,15 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.count, 1, "Closing the last surface should not remove the workspace")
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
XCTAssertNil(workspace.panels[initialPanelId], "Expected the original surface to be closed")
XCTAssertEqual(workspace.panels.count, 1, "Expected the workspace to stay alive with a replacement surface")
XCTAssertNil(workspace.panels[initialPanelId])
XCTAssertEqual(workspace.panels.count, 1)
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
}
func testCloseCurrentPanelClosesWorkspaceWhenLastSurfaceShortcutSettingEnabled() {
let defaults = UserDefaults.standard
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
defer {
if let originalSetting {
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
} else {
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
}
}
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
manager.closeCurrentPanelWithConfirmation()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
}
func testClosePanelButtonClosesWorkspaceWhenLastSurfaceShortcutSettingEnabled() {
let defaults = UserDefaults.standard
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
defer {
if let originalSetting {
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
} else {
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
}
}
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
}
func testCloseCurrentPanelWithLegacySettingIgnoresStaleSurfaceId() {
let defaults = UserDefaults.standard
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
defer {
if let originalSetting {
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
} else {
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
}
}
func testCloseCurrentPanelIgnoresStaleSurfaceId() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit fa452db181f361514087558a29204bda7e38218f
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826