From 28b4e2495db53c707d4e6616af136eadd1eba393 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Mar 2026 14:31:03 -0800 Subject: [PATCH] macos: Add AppleScript commands for window and tab control Add scripting dictionary commands for activating windows, selecting tabs, closing tabs, and closing windows. Implement the corresponding Cocoa AppleScript command handlers and expose minimal ScriptWindow/ScriptTab helpers needed to resolve live targets. Verified by building Ghostty and running osascript commands against the absolute Debug app path to exercise all four new commands. --- macos/Ghostty.sdef | 28 +++++++++ .../AppleScript/ScriptCloseCommand.swift | 60 +++++++++++++++++++ .../AppleScript/ScriptFocusCommand.swift | 46 ++++++++++++++ .../Features/AppleScript/ScriptTab.swift | 10 ++++ .../Features/AppleScript/ScriptWindow.swift | 5 ++ 5 files changed, 149 insertions(+) diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef index 47fae636f..7cbe898a1 100644 --- a/macos/Ghostty.sdef +++ b/macos/Ghostty.sdef @@ -182,6 +182,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/AppleScript/ScriptCloseCommand.swift b/macos/Sources/Features/AppleScript/ScriptCloseCommand.swift index e8fdbb0a9..3f240cf6e 100644 --- a/macos/Sources/Features/AppleScript/ScriptCloseCommand.swift +++ b/macos/Sources/Features/AppleScript/ScriptCloseCommand.swift @@ -31,3 +31,63 @@ final class ScriptCloseCommand: NSScriptCommand { return nil } } + +/// Handler for the `close tab` AppleScript command defined in `Ghostty.sdef`. +@MainActor +@objc(GhosttyScriptCloseTabCommand) +final class ScriptCloseTabCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard let tab = evaluatedArguments?["tab"] as? ScriptTab else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing tab target." + return nil + } + + guard let controller = tab.parentController else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Tab is no longer available." + return nil + } + + if let terminalController = controller as? TerminalController { + terminalController.closeTabImmediately(registerRedo: false) + return nil + } + + guard let targetWindow = tab.parentWindow else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Tab window is no longer available." + return nil + } + + targetWindow.close() + return nil + } +} + +/// Handler for the `close window` AppleScript command defined in `Ghostty.sdef`. +@MainActor +@objc(GhosttyScriptCloseWindowCommand) +final class ScriptCloseWindowCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard let window = evaluatedArguments?["window"] as? ScriptWindow else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing window target." + return nil + } + + if let terminalController = window.preferredController as? TerminalController { + terminalController.closeWindowImmediately() + return nil + } + + guard let targetWindow = window.preferredParentWindow else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Window is no longer available." + return nil + } + + targetWindow.close() + return nil + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptFocusCommand.swift b/macos/Sources/Features/AppleScript/ScriptFocusCommand.swift index 024aee4de..edda43a45 100644 --- a/macos/Sources/Features/AppleScript/ScriptFocusCommand.swift +++ b/macos/Sources/Features/AppleScript/ScriptFocusCommand.swift @@ -31,3 +31,49 @@ final class ScriptFocusCommand: NSScriptCommand { return nil } } + +/// Handler for the `activate window` AppleScript command defined in `Ghostty.sdef`. +@MainActor +@objc(GhosttyScriptActivateWindowCommand) +final class ScriptActivateWindowCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard let window = evaluatedArguments?["window"] as? ScriptWindow else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing window target." + return nil + } + + guard let targetWindow = window.preferredParentWindow else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Window is no longer available." + return nil + } + + targetWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return nil + } +} + +/// Handler for the `select tab` AppleScript command defined in `Ghostty.sdef`. +@MainActor +@objc(GhosttyScriptSelectTabCommand) +final class ScriptSelectTabCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard let tab = evaluatedArguments?["tab"] as? ScriptTab else { + scriptErrorNumber = errAEParamMissed + scriptErrorString = "Missing tab target." + return nil + } + + guard let targetWindow = tab.parentWindow else { + scriptErrorNumber = errAEEventFailed + scriptErrorString = "Tab is no longer available." + return nil + } + + targetWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return nil + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptTab.swift b/macos/Sources/Features/AppleScript/ScriptTab.swift index aa643db5a..bda694ebc 100644 --- a/macos/Sources/Features/AppleScript/ScriptTab.swift +++ b/macos/Sources/Features/AppleScript/ScriptTab.swift @@ -63,6 +63,16 @@ final class ScriptTab: NSObject { return window?.tabIsSelected(controller) ?? false } + /// Best-effort native window containing this tab. + var parentWindow: NSWindow? { + controller?.window + } + + /// Live controller backing this tab wrapper. + var parentController: BaseTerminalController? { + controller + } + /// Exposed as the AppleScript `terminals` element on a tab. /// /// Returns all terminal surfaces (split panes) within this tab. diff --git a/macos/Sources/Features/AppleScript/ScriptWindow.swift b/macos/Sources/Features/AppleScript/ScriptWindow.swift index 089ea5e18..97822e973 100644 --- a/macos/Sources/Features/AppleScript/ScriptWindow.swift +++ b/macos/Sources/Features/AppleScript/ScriptWindow.swift @@ -116,6 +116,11 @@ final class ScriptWindow: NSObject { selectedController?.window ?? controllers.first?.window } + /// Best-effort controller to use for window-scoped AppleScript commands. + var preferredController: BaseTerminalController? { + selectedController ?? controllers.first + } + /// Resolves a previously generated tab ID back to a live controller. private func controller(tabID: String) -> BaseTerminalController? { controllers.first(where: { ScriptTab.stableID(controller: $0) == tabID })