From 625d7274bf0bcebf17b5cd4ffa853165269489a6 Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 20:15:53 +0200 Subject: [PATCH 1/8] Add close tabs on the right action --- include/ghostty.h | 1 + .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- .../Window Styles/TerminalWindow.swift | 60 ++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 7 ++ macos/Sources/Ghostty/Package.swift | 3 + pkg/apple-sdk/build.zig | 27 ++++++ src/Surface.zig | 1 + src/apprt/action.zig | 2 + src/apprt/gtk/class/tab.zig | 1 + src/apprt/gtk/ui/1.5/window.blp | 30 ++++++ src/input/Binding.zig | 6 +- src/input/command.zig | 5 + 12 files changed, 231 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6cafe8773..cb8646560 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5cc2c67f1..1083fb405 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -104,6 +104,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr selector: #selector(onCloseOtherTabs), name: .ghosttyCloseOtherTabs, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTabsOnTheRight), + name: .ghosttyCloseTabsOnTheRight, + object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), @@ -627,6 +632,48 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + private func closeTabsOnTheRightImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + if let undoManager { + undoManager.beginUndoGrouping() + } + defer { + undoManager?.endUndoGrouping() + } + + for (_, candidate) in tabsToClose { + if let controller = candidate.windowController as? TerminalController { + controller.closeTabImmediately(registerRedo: false) + } + } + + if let undoManager { + undoManager.setActionName("Close Tabs on the Right") + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + DispatchQueue.main.async { + target.window?.makeKeyAndOrderFront(nil) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.closeTabsOnTheRightImmediately() + } + } + } + } + /// Closes the current window (including any other tabs) immediately and without /// confirmation. This will setup proper undo state so the action can be undone. private func closeWindowImmediately() { @@ -1078,24 +1125,24 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } - + // Check if we have to confirm close. guard tabGroup.windows.contains(where: { window in // Ignore ourself if window == self.window { return false } - + // Ignore non-terminals guard let controller = window.windowController as? TerminalController else { return false } - + // Check if any surfaces require confirmation return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) else { self.closeOtherTabsImmediately() return } - + confirmClose( messageText: "Close Other Tabs?", informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed." @@ -1104,6 +1151,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + @IBAction func closeTabsOnTheRight(_ sender: Any?) { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + let needsConfirm = tabsToClose.contains { (_, candidate) in + guard let controller = candidate.windowController as? TerminalController else { + return false + } + + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) + } + + if !needsConfirm { + self.closeTabsOnTheRightImmediately() + return + } + + confirmClose( + messageText: "Close Tabs on the Right?", + informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabsOnTheRightImmediately() + } + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1305,6 +1381,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeOtherTabs(self) } + @objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + closeTabsOnTheRight(self) + } + @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } @@ -1367,6 +1449,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr extension TerminalController { override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { + case #selector(closeTabsOnTheRight): + guard let window, let tabGroup = window.tabGroup else { return false } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } + return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2208d99cf..cbbbf99f7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -26,6 +26,8 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + private var tabMenuObserver: NSObjectProtocol? = nil /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -53,6 +55,15 @@ class TerminalWindow: NSWindow { override func awakeFromNib() { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + + tabMenuObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let menu = note.object as? NSMenu else { return } + self.configureTabContextMenuIfNeeded(menu) + } // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then @@ -202,6 +213,8 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -277,6 +290,47 @@ class TerminalWindow: NSWindow { } } + private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + menu.removeItem(existing) + } + guard let terminalController else { return } + + let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = terminalController + item.isEnabled = true + + let closeOtherIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("other") && name.contains("tab") + }) + + let closeThisIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("tab") + }) + + if let idx = closeOtherIndex { + menu.insertItem(item, at: idx + 1) + } else if let idx = closeThisIndex { + menu.insertItem(item, at: idx + 1) + } else { + menu.addItem(item) + } + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } + return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + } + + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -517,6 +571,12 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + deinit { + if let observer = tabMenuObserver { + NotificationCenter.default.removeObserver(observer) + } + } // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 39ebbb51f..f6452e54e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -861,6 +861,13 @@ extension Ghostty { ) return + case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: + NotificationCenter.default.post( + name: .ghosttyCloseTabsOnTheRight, + object: surfaceView + ) + return + default: assertionFailure() } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 7ee815caa..4b3eb60aa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -380,6 +380,9 @@ extension Notification.Name { /// Close other tabs static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs") + /// Close tabs to the right of the focused tab + static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight") + /// Close window static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow") diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index c573c3910..32cb726fd 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,6 +30,7 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, + cxx_include: []const u8, }) = .{}; }; @@ -82,11 +83,36 @@ pub fn addPaths( }); }; + const cxx_include_path = cxx: { + const preferred = try std.fs.path.join(b.allocator, &.{ + libc.sys_include_dir.?, + "c++", + "v1", + }); + if (std.fs.accessAbsolute(preferred, .{})) |_| { + break :cxx preferred; + } else |_| {} + + const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; + const fallback = try std.fs.path.join(b.allocator, &.{ + sdk_root, + "include", + "c++", + "v1", + }); + if (std.fs.accessAbsolute(fallback, .{})) |_| { + break :cxx fallback; + } else |_| {} + + break :cxx preferred; + }; + gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, + .cxx_include = cxx_include_path, }; } @@ -107,5 +133,6 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/Surface.zig b/src/Surface.zig index 653178bdc..9e7ad0b97 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool switch (v) { .this => .this, .other => .other, + .right => .right, }, ), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 00bf8685a..365f525f8 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -767,6 +767,8 @@ pub const CloseTabMode = enum(c_int) { this, /// Close all other tabs. other, + /// Close all tabs to the right of the current tab. + right, }; pub const CommandFinished = struct { diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c8b5607a6..fb3b8b0ef 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -347,6 +347,7 @@ pub const Tab = extern struct { switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), } } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb..de06b04da 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,6 +162,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); + menu-model: tab_context_menu; shortcuts: none; } } @@ -192,6 +193,35 @@ menu split_menu { } } +menu tab_context_menu { + section { + item { + label: _("New Tab"); + action: "win.new-tab"; + } + } + + section { + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + + item { + label: _("Close Other Tabs"); + action: "tab.close"; + target: "other"; + } + + item { + label: _("Close Tabs on the Right"); + action: "tab.close"; + target: "right"; + } + } +} + menu main_menu { section { item { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1e7db3592..66fe03651 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -600,9 +600,8 @@ pub const Action = union(enum) { /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab and all splits therein _or_ close all tabs and - /// splits thein of tabs _other_ than the current tab, depending on the - /// mode. + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. /// /// If the mode is not specified, defaults to closing the current tab. /// @@ -1005,6 +1004,7 @@ pub const Action = union(enum) { pub const CloseTabMode = enum { this, other, + right, pub const default: CloseTabMode = .this; }; diff --git a/src/input/command.zig b/src/input/command.zig index 72fb7f4ee..6baeca23b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -538,6 +538,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Close Other Tabs", .description = "Close all tabs in this window except the current one.", }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs on the Right", + .description = "Close every tab to the right of the current one.", + }, }, .close_window => comptime &.{.{ From cca10f3ca8b701c9c34bbcd1fc918e0de3e004e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:17:25 -0800 Subject: [PATCH 2/8] Revert GTK UI changes, apple-sdk build stuff --- pkg/apple-sdk/build.zig | 27 --------------------------- src/apprt/gtk/ui/1.5/window.blp | 30 ------------------------------ src/input/command.zig | 2 +- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index 32cb726fd..c573c3910 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,7 +30,6 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, - cxx_include: []const u8, }) = .{}; }; @@ -83,36 +82,11 @@ pub fn addPaths( }); }; - const cxx_include_path = cxx: { - const preferred = try std.fs.path.join(b.allocator, &.{ - libc.sys_include_dir.?, - "c++", - "v1", - }); - if (std.fs.accessAbsolute(preferred, .{})) |_| { - break :cxx preferred; - } else |_| {} - - const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; - const fallback = try std.fs.path.join(b.allocator, &.{ - sdk_root, - "include", - "c++", - "v1", - }); - if (std.fs.accessAbsolute(fallback, .{})) |_| { - break :cxx fallback; - } else |_| {} - - break :cxx preferred; - }; - gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, - .cxx_include = cxx_include_path, }; } @@ -133,6 +107,5 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); - step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index de06b04da..8c0a7bedb 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,7 +162,6 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); - menu-model: tab_context_menu; shortcuts: none; } } @@ -193,35 +192,6 @@ menu split_menu { } } -menu tab_context_menu { - section { - item { - label: _("New Tab"); - action: "win.new-tab"; - } - } - - section { - item { - label: _("Close Tab"); - action: "tab.close"; - target: "this"; - } - - item { - label: _("Close Other Tabs"); - action: "tab.close"; - target: "other"; - } - - item { - label: _("Close Tabs on the Right"); - action: "tab.close"; - target: "right"; - } - } -} - menu main_menu { section { item { diff --git a/src/input/command.zig b/src/input/command.zig index 6baeca23b..4cbe9ffc4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -540,7 +540,7 @@ fn actionCommands(action: Action.Key) []const Command { }, .{ .action = .{ .close_tab = .right }, - .title = "Close Tabs on the Right", + .title = "Close Tabs to the Right", .description = "Close every tab to the right of the current one.", }, }, From 4424451c59eb16189054b1787b247e762fe74c4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:24:54 -0800 Subject: [PATCH 3/8] macos: remove to "close to the right" --- macos/Sources/Features/Terminal/TerminalController.swift | 6 ++---- .../Features/Terminal/Window Styles/TerminalWindow.swift | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 1083fb405..a275c3f39 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -640,9 +640,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } guard !tabsToClose.isEmpty else { return } - if let undoManager { - undoManager.beginUndoGrouping() - } + undoManager?.beginUndoGrouping() defer { undoManager?.endUndoGrouping() } @@ -654,7 +652,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } if let undoManager { - undoManager.setActionName("Close Tabs on the Right") + undoManager.setActionName("Close Tabs to the Right") undoManager.registerUndo( withTarget: self, diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cbbbf99f7..1f9f10502 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -297,7 +297,7 @@ class TerminalWindow: NSWindow { } guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = terminalController From f612e4632cc84ebad71c266c704f0d5bcfc1f829 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:43:38 -0800 Subject: [PATCH 4/8] macos: clean up some style on tab bar context menu configuring --- .../Window Styles/TerminalWindow.swift | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 1f9f10502..0ae4c3b02 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -56,12 +56,14 @@ class TerminalWindow: NSWindow { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + // This is fragile, but there doesn't seem to be an official API for customizing + // native tab bar menus. tabMenuObserver = NotificationCenter.default.addObserver( forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), object: nil, queue: .main - ) { [weak self] note in - guard let self, let menu = note.object as? NSMenu else { return } + ) { [weak self] n in + guard let self, let menu = n.object as? NSMenu else { return } self.configureTabContextMenuIfNeeded(menu) } @@ -292,32 +294,26 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } - if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + + let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = nil + item.isEnabled = true + + // Remove any previously configured items, because the menu is + // cached across different tab targets. + if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { menu.removeItem(existing) } - guard let terminalController else { return } - let title = NSLocalizedString("Close Tabs to the Right", comment: "Tab context menu option") - let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") - item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = terminalController - item.isEnabled = true - - let closeOtherIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("other") && name.contains("tab") - }) - - let closeThisIndex = menu.items.firstIndex(where: { menuItem in - guard let action = menuItem.action else { return false } - let name = NSStringFromSelector(action).lowercased() - return name.contains("close") && name.contains("tab") - }) - - if let idx = closeOtherIndex { + // Insert it wherever we can + if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performCloseOtherTabs:") + }) { menu.insertItem(item, at: idx + 1) - } else if let idx = closeThisIndex { + } else if let idx = menu.items.firstIndex(where: { + $0.action == NSSelectorFromString("performClose:") + }) { menu.insertItem(item, at: idx + 1) } else { menu.addItem(item) @@ -326,8 +322,17 @@ class TerminalWindow: NSWindow { private func isTabContextMenu(_ menu: NSMenu) -> Bool { guard NSApp.keyWindow === self else { return false } - let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } - return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + + // These are the target selectors, at least for macOS 26. + let tabContextSelectors: Set = [ + "performClose:", + "performCloseOtherTabs:", + "moveTabToNewWindow:", + "toggleTabOverview:" + ] + + let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) }) + return !selectorNames.isDisjoint(with: tabContextSelectors) } From dc641c7861c44b8ecdfb8a3747d99c8bc5360e41 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:47:15 -0800 Subject: [PATCH 5/8] macos: change to NSMenu extension --- .../Window Styles/TerminalWindow.swift | 19 ++---------- .../Helpers/Extensions/NSMenu+Extension.swift | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSMenu+Extension.swift diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 0ae4c3b02..997996e3b 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -299,23 +299,8 @@ class TerminalWindow: NSWindow { item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = nil item.isEnabled = true - - // Remove any previously configured items, because the menu is - // cached across different tab targets. - if let existing = menu.items.first(where: { $0.identifier == item.identifier }) { - menu.removeItem(existing) - } - - // Insert it wherever we can - if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performCloseOtherTabs:") - }) { - menu.insertItem(item, at: idx + 1) - } else if let idx = menu.items.firstIndex(where: { - $0.action == NSSelectorFromString("performClose:") - }) { - menu.insertItem(item, at: idx + 1) - } else { + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && + !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } } diff --git a/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift new file mode 100644 index 000000000..7ddfa419f --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSMenu+Extension.swift @@ -0,0 +1,29 @@ +import AppKit + +extension NSMenu { + /// Inserts a menu item after an existing item with the specified action selector. + /// + /// If an item with the same identifier already exists, it is removed first to avoid duplicates. + /// This is useful when menus are cached and reused across different targets. + /// + /// - Parameters: + /// - item: The menu item to insert. + /// - action: The action selector to search for. The new item will be inserted after the first + /// item with this action. + /// - Returns: `true` if the item was inserted after the specified action, `false` if the action + /// was not found and the item was not inserted. + @discardableResult + func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool { + if let identifier = item.identifier, + let existing = items.first(where: { $0.identifier == identifier }) { + removeItem(existing) + } + + guard let idx = items.firstIndex(where: { $0.action == action }) else { + return false + } + + insertItem(item, at: idx + 1) + return true + } +} From 1387dbefad18809a95a5eaacb7a3f223891d0e9c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:50:26 -0800 Subject: [PATCH 6/8] macos: target should be the correct target --- .../Terminal/Window Styles/TerminalWindow.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 997996e3b..b8c9d4c7d 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -294,11 +294,19 @@ class TerminalWindow: NSWindow { private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { guard isTabContextMenu(menu) else { return } + + // Get the target from an existing menu item. The native tab context menu items + // target the specific window/controller that was right-clicked, not the focused one. + // We need to use that same target so validation and action use the correct tab. + let targetController = menu.items + .first { $0.action == NSSelectorFromString("performClose:") } + .flatMap { $0.target as? NSWindow } + .flatMap { $0.windowController as? TerminalController } let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier - item.target = nil - item.isEnabled = true + item.target = targetController + if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) From eb75d48e6b59f32cbad65ee7233586aa84940541 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:56:07 -0800 Subject: [PATCH 7/8] macos: add xmark to other tab close items --- .../Terminal/Window Styles/TerminalWindow.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index b8c9d4c7d..77ee98cb4 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -303,14 +303,23 @@ class TerminalWindow: NSWindow { .flatMap { $0.target as? NSWindow } .flatMap { $0.windowController as? TerminalController } + // Close tabs to the right let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") item.identifier = Self.closeTabsOnRightMenuItemIdentifier item.target = targetController - + item.setImageIfDesired(systemSymbolName: "xmark") if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) && !menu.insertItem(item, after: NSSelectorFromString("performClose:")) { menu.addItem(item) } + + // Other close items should have the xmark to match Safari on macOS 26 + for menuItem in menu.items { + if menuItem.action == NSSelectorFromString("performClose:") || + menuItem.action == NSSelectorFromString("performCloseOtherTabs:") { + menuItem.setImageIfDesired(systemSymbolName: "xmark") + } + } } private func isTabContextMenu(_ menu: NSMenu) -> Bool { From 3352d5f0810200e74b1bd537f6c70a3a3018e957 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 20:57:36 -0800 Subject: [PATCH 8/8] Fix up close right description --- src/input/command.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input/command.zig b/src/input/command.zig index 4cbe9ffc4..b3f9e86b6 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -541,7 +541,7 @@ fn actionCommands(action: Action.Key) []const Command { .{ .action = .{ .close_tab = .right }, .title = "Close Tabs to the Right", - .description = "Close every tab to the right of the current one.", + .description = "Close all tabs to the right of the current one.", }, },