diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef
index dcb973974..e2410a4bd 100644
--- a/macos/Ghostty.sdef
+++ b/macos/Ghostty.sdef
@@ -14,11 +14,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index 6b89e28c9..f667bb76e 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -146,7 +146,9 @@
Features/AppleScript/ScriptMousePosCommand.swift,
Features/AppleScript/ScriptMouseScrollCommand.swift,
Features/AppleScript/ScriptSplitCommand.swift,
+ Features/AppleScript/ScriptTab.swift,
Features/AppleScript/ScriptTerminal.swift,
+ Features/AppleScript/ScriptWindow.swift,
Features/ClipboardConfirmation/ClipboardConfirmation.xib,
Features/ClipboardConfirmation/ClipboardConfirmationController.swift,
Features/ClipboardConfirmation/ClipboardConfirmationView.swift,
diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
index 267863712..519d2805f 100644
--- a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
+++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
@@ -2,10 +2,74 @@ import AppKit
/// Application-level Cocoa scripting hooks for the Ghostty AppleScript dictionary.
///
-/// Cocoa scripting looks for specifically named Objective-C selectors derived
-/// from the `sdef` file. This extension implements those required entry points
-/// on `NSApplication`, which is the object behind the `application` class in
+/// Cocoa scripting is mostly convention-based: we do not register handlers in
+/// code, we expose Objective-C selectors with names Cocoa derives from
/// `Ghostty.sdef`.
+///
+/// In practical terms:
+/// - An `` in `sdef` maps to an ObjC collection accessor.
+/// - Unique-ID element lookup maps to `valueIn...WithUniqueID:`.
+/// - Some `` declarations map to `handle...ScriptCommand:`.
+///
+/// This file implements the selectors Cocoa expects on `NSApplication`, which is
+/// the runtime object behind the `application` class in `Ghostty.sdef`.
+
+// MARK: - Windows
+
+@MainActor
+extension NSApplication {
+ /// Backing collection for `application.windows`.
+ ///
+ /// We expose one scripting window per native tab group so scripts see the
+ /// expected window/tab hierarchy instead of one AppKit window per tab.
+ ///
+ /// Required selector name from the `sdef` element key: `scriptWindows`.
+ ///
+ /// Cocoa scripting calls this whenever AppleScript evaluates a window list,
+ /// such as `windows`, `window 1`, or `every window whose ...`.
+ @objc(scriptWindows)
+ var scriptWindows: [ScriptWindow] {
+ // AppKit exposes one NSWindow per tab. AppleScript users expect one
+ // top-level window object containing multiple tabs, so we dedupe tab
+ // siblings into a single ScriptWindow.
+ var seen: Set = []
+ var result: [ScriptWindow] = []
+
+ for controller in orderedTerminalControllers {
+ // Collapse each controller to one canonical representative for the
+ // whole tab group. Standalone windows map to themselves.
+ guard let primary = primaryTerminalController(for: controller) else {
+ continue
+ }
+
+ let primaryControllerID = ObjectIdentifier(primary)
+ guard seen.insert(primaryControllerID).inserted else {
+ // Another tab from this group already created the scripting
+ // window object.
+ continue
+ }
+
+ result.append(ScriptWindow(primaryController: primary))
+ }
+
+ return result
+ }
+
+ /// Enables AppleScript unique-ID lookup for window references.
+ ///
+ /// Required selector name pattern for element key `scriptWindows`:
+ /// `valueInScriptWindowsWithUniqueID:`.
+ ///
+ /// Cocoa calls this when a script resolves `window id "..."`.
+ /// Returning `nil` makes the object specifier fail naturally.
+ @objc(valueInScriptWindowsWithUniqueID:)
+ func valueInScriptWindows(uniqueID: String) -> ScriptWindow? {
+ scriptWindows.first(where: { $0.stableID == uniqueID })
+ }
+}
+
+// MARK: - Terminals
+
@MainActor
extension NSApplication {
/// Backing collection for `application.terminals`.
@@ -29,7 +93,12 @@ extension NSApplication {
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
}
+}
+// MARK: - Commands
+
+@MainActor
+extension NSApplication {
/// Handler for the `perform action` AppleScript command.
///
/// Required selector name from the command in `sdef`:
@@ -56,12 +125,43 @@ extension NSApplication {
return NSNumber(value: terminal.perform(action: action))
}
+}
+// MARK: - Private Helpers
+
+@MainActor
+extension NSApplication {
/// Discovers all currently alive terminal surfaces across normal and quick
/// terminal windows. This powers both terminal enumeration and ID lookup.
- private var allSurfaceViews: [Ghostty.SurfaceView] {
- NSApp.windows
- .compactMap { $0.windowController as? BaseTerminalController }
+ fileprivate var allSurfaceViews: [Ghostty.SurfaceView] {
+ allTerminalControllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
}
+
+ /// All terminal controllers in undefined order.
+ fileprivate var allTerminalControllers: [BaseTerminalController] {
+ NSApp.windows.compactMap { $0.windowController as? BaseTerminalController }
+ }
+
+ /// All terminal controllers in front-to-back order.
+ fileprivate var orderedTerminalControllers: [BaseTerminalController] {
+ NSApp.orderedWindows.compactMap { $0.windowController as? BaseTerminalController }
+ }
+
+ /// Identifies the primary tab controller for a window's tab group.
+ ///
+ /// This gives us one stable representative for all tabs in the same native
+ /// AppKit tab group.
+ ///
+ /// For standalone windows this returns the window's controller directly.
+ /// For tabbed windows, "primary" is currently the first controller in the
+ /// tab group's ordered windows list.
+ fileprivate func primaryTerminalController(for controller: BaseTerminalController) -> BaseTerminalController? {
+ guard let window = controller.window else { return nil }
+ guard let tabGroup = window.tabGroup else { return controller }
+
+ return tabGroup.windows
+ .compactMap { $0.windowController as? BaseTerminalController }
+ .first
+ }
}
diff --git a/macos/Sources/Features/AppleScript/ScriptTab.swift b/macos/Sources/Features/AppleScript/ScriptTab.swift
new file mode 100644
index 000000000..433da8160
--- /dev/null
+++ b/macos/Sources/Features/AppleScript/ScriptTab.swift
@@ -0,0 +1,85 @@
+import AppKit
+
+/// AppleScript-facing wrapper around a single tab in a scripting window.
+///
+/// `ScriptWindow.tabs` vends these objects so AppleScript can traverse
+/// `window -> tab` without knowing anything about AppKit controllers.
+@MainActor
+@objc(GhosttyScriptTab)
+final class ScriptTab: NSObject {
+ /// Stable identifier used by AppleScript `tab id "..."` references.
+ private let stableID: String
+
+ /// Weak back-reference to the scripting window that owns this tab wrapper.
+ ///
+ /// We only need this for dynamic properties (`index`, `selected`) and for
+ /// building an object specifier path.
+ private weak var window: ScriptWindow?
+
+ /// Live terminal controller for this tab.
+ ///
+ /// This can become `nil` if the tab closes while a script is running.
+ private weak var controller: BaseTerminalController?
+
+ /// Called by `ScriptWindow.tabs` / `ScriptWindow.selectedTab`.
+ ///
+ /// The ID is computed once so object specifiers built from this instance keep
+ /// a consistent tab identity.
+ init(window: ScriptWindow, controller: BaseTerminalController) {
+ self.stableID = Self.stableID(controller: controller)
+ self.window = window
+ self.controller = controller
+ }
+
+ /// Exposed as the AppleScript `id` property.
+ @objc(id)
+ var idValue: String {
+ stableID
+ }
+
+ /// Exposed as the AppleScript `index` property.
+ ///
+ /// Cocoa scripting expects this to be 1-based for user-facing collections.
+ @objc(index)
+ var index: Int {
+ guard let controller else { return 0 }
+ return window?.tabIndex(for: controller) ?? 0
+ }
+
+ /// Exposed as the AppleScript `selected` property.
+ ///
+ /// Powers script conditions such as `if selected of tab 1 then ...`.
+ @objc(selected)
+ var selected: Bool {
+ guard let controller else { return false }
+ return window?.tabIsSelected(controller) ?? false
+ }
+
+ /// Provides Cocoa scripting with a canonical "path" back to this object.
+ override var objectSpecifier: NSScriptObjectSpecifier? {
+ guard let window else { return nil }
+ guard let windowClassDescription = window.classDescription as? NSScriptClassDescription else {
+ return nil
+ }
+ guard let windowSpecifier = window.objectSpecifier else { return nil }
+
+ // This tells Cocoa how to re-find this tab later:
+ // application -> scriptWindows[id] -> tabs[id].
+ return NSUniqueIDSpecifier(
+ containerClassDescription: windowClassDescription,
+ containerSpecifier: windowSpecifier,
+ key: "tabs",
+ uniqueID: stableID
+ )
+ }
+}
+
+extension ScriptTab {
+ /// Stable ID for one tab controller.
+ ///
+ /// Tab identity belongs to `ScriptTab`, so both tab creation and tab ID
+ /// lookups in `ScriptWindow` call this helper.
+ static func stableID(controller: BaseTerminalController) -> String {
+ "tab-\(ObjectIdentifier(controller).hexString)"
+ }
+}
diff --git a/macos/Sources/Features/AppleScript/ScriptWindow.swift b/macos/Sources/Features/AppleScript/ScriptWindow.swift
new file mode 100644
index 000000000..eb00d720b
--- /dev/null
+++ b/macos/Sources/Features/AppleScript/ScriptWindow.swift
@@ -0,0 +1,174 @@
+import AppKit
+
+/// AppleScript-facing wrapper around a logical Ghostty window.
+///
+/// In AppKit, each tab is often its own `NSWindow`. AppleScript users, however,
+/// expect a single window object containing a list of tabs.
+///
+/// `ScriptWindow` is that compatibility layer:
+/// - It presents one object per tab group.
+/// - It translates tab-group state into `tabs` and `selected tab`.
+/// - It exposes stable IDs that Cocoa scripting can resolve later.
+@MainActor
+@objc(GhosttyScriptWindow)
+final class ScriptWindow: NSObject {
+ /// Stable identifier used by AppleScript `window id "..."` references.
+ ///
+ /// We precompute this once so the object keeps a consistent ID for its whole
+ /// lifetime, even if AppKit window bookkeeping changes after creation.
+ let stableID: String
+
+ /// Canonical representative for this scripting window's tab group.
+ ///
+ /// We intentionally keep only one controller reference; full tab membership
+ /// is derived lazily from current AppKit state whenever needed.
+ private weak var primaryController: BaseTerminalController?
+
+ /// `scriptWindows` in `AppDelegate+AppleScript` constructs these objects.
+ ///
+ /// `stableID` must match the same identity scheme used by
+ /// `valueInScriptWindowsWithUniqueID:` so Cocoa can re-resolve object
+ /// specifiers produced earlier in a script.
+ init(primaryController: BaseTerminalController) {
+ self.stableID = Self.stableID(primaryController: primaryController)
+ self.primaryController = primaryController
+ }
+
+ /// Exposed as the AppleScript `id` property.
+ ///
+ /// This is what scripts read with `id of window ...`.
+ @objc(id)
+ var idValue: String {
+ stableID
+ }
+
+ /// Exposed as the AppleScript `tabs` element.
+ ///
+ /// Cocoa asks for this collection when a script evaluates `tabs of window ...`
+ /// or any tab-filter expression. We build wrappers from live controller state
+ /// so tab additions/removals are reflected immediately.
+ @objc(tabs)
+ var tabs: [ScriptTab] {
+ controllers.map { ScriptTab(window: self, controller: $0) }
+ }
+
+ /// Exposed as the AppleScript `selected tab` property.
+ ///
+ /// This powers expressions like `selected tab of window 1`.
+ @objc(selectedTab)
+ var selectedTab: ScriptTab? {
+ guard let selectedController else { return nil }
+ return ScriptTab(window: self, controller: selectedController)
+ }
+
+ /// Enables unique-ID lookup for `tabs` references.
+ ///
+ /// Required selector pattern for the `tabs` element key:
+ /// `valueInTabsWithUniqueID:`.
+ ///
+ /// Cocoa uses this when a script resolves `tab id "..." of window ...`.
+ @objc(valueInTabsWithUniqueID:)
+ func valueInTabs(uniqueID: String) -> ScriptTab? {
+ guard let controller = controller(tabID: uniqueID) else { return nil }
+ return ScriptTab(window: self, controller: controller)
+ }
+
+ /// AppleScript tab indexes are 1-based, so we add one to Swift's 0-based
+ /// array index.
+ func tabIndex(for controller: BaseTerminalController) -> Int? {
+ controllers.firstIndex(where: { $0 === controller }).map { $0 + 1 }
+ }
+
+ /// Reports whether a given controller maps to this window's selected tab.
+ func tabIsSelected(_ controller: BaseTerminalController) -> Bool {
+ selectedController === controller
+ }
+
+ /// 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 })
+ }
+
+ /// Live controller list for this scripting window.
+ ///
+ /// We recalculate on every access so AppleScript immediately sees tab-group
+ /// changes (new tabs, closed tabs, tab moves) without rebuilding all objects.
+ private var controllers: [BaseTerminalController] {
+ guard let primaryController else { return [] }
+ guard let window = primaryController.window else { return [primaryController] }
+
+ if let tabGroup = window.tabGroup {
+ let groupControllers = tabGroup.windows.compactMap {
+ $0.windowController as? BaseTerminalController
+ }
+ if !groupControllers.isEmpty {
+ return groupControllers
+ }
+ }
+
+ return [primaryController]
+ }
+
+ /// Live selected controller for this scripting window.
+ ///
+ /// AppKit tracks selected tab on `NSWindowTabGroup.selectedWindow`; for
+ /// non-tabbed windows we fall back to the primary controller.
+ private var selectedController: BaseTerminalController? {
+ guard let primaryController else { return nil }
+ guard let window = primaryController.window else { return primaryController }
+
+ if let tabGroup = window.tabGroup,
+ let selectedController = tabGroup.selectedWindow?.windowController as? BaseTerminalController {
+ return selectedController
+ }
+
+ return controllers.first
+ }
+
+ /// Provides Cocoa scripting with a canonical "path" back to this object.
+ ///
+ /// Without this, Cocoa can return data but cannot reliably build object
+ /// references for later script statements. This specifier encodes:
+ /// `application -> scriptWindows[id]`.
+ override var objectSpecifier: NSScriptObjectSpecifier? {
+ guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
+ return nil
+ }
+
+ return NSUniqueIDSpecifier(
+ containerClassDescription: appClassDescription,
+ containerSpecifier: nil,
+ key: "scriptWindows",
+ uniqueID: stableID
+ )
+ }
+}
+
+extension ScriptWindow {
+ /// Produces the window-level stable ID from the primary controller.
+ ///
+ /// - Tabbed windows are keyed by tab-group identity.
+ /// - Standalone windows are keyed by window identity.
+ /// - Detached controllers fall back to controller identity.
+ static func stableID(primaryController: BaseTerminalController) -> String {
+ guard let window = primaryController.window else {
+ return "controller-\(ObjectIdentifier(primaryController).hexString)"
+ }
+
+ if let tabGroup = window.tabGroup {
+ return stableID(tabGroup: tabGroup)
+ }
+
+ return stableID(window: window)
+ }
+
+ /// Stable ID for a standalone native window.
+ static func stableID(window: NSWindow) -> String {
+ "window-\(ObjectIdentifier(window).hexString)"
+ }
+
+ /// Stable ID for a native AppKit tab group.
+ static func stableID(tabGroup: NSWindowTabGroup) -> String {
+ "tab-group-\(ObjectIdentifier(tabGroup).hexString)"
+ }
+}
diff --git a/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift
new file mode 100644
index 000000000..d2440f1d4
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+extension ObjectIdentifier {
+ var hexString: String {
+ String(UInt(bitPattern: self), radix: 16)
+ }
+}