diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef
index c39a3c8a1..5b77e29b2 100644
--- a/macos/Ghostty.sdef
+++ b/macos/Ghostty.sdef
@@ -16,6 +16,9 @@
+
+
+
@@ -29,6 +32,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -74,7 +99,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
index cbeda86eb..acfc943db 100644
--- a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
+++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
@@ -126,11 +126,28 @@ extension NSApplication {
return NSNumber(value: terminal.perform(action: action))
}
+ /// Handler for creating a reusable AppleScript surface configuration object.
+ @objc(handleNewSurfaceConfigurationScriptCommand:)
+ func handleNewSurfaceConfigurationScriptCommand(_ command: NSScriptCommand) -> Any? {
+ do {
+ let configuration = try Ghostty.SurfaceConfiguration(
+ scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary
+ )
+ return configuration.dictionaryRepresentation
+ } catch {
+ command.scriptErrorNumber = errAECoercionFail
+ command.scriptErrorString = error.localizedDescription
+ return nil
+ }
+ }
+
/// Handler for the `new window` AppleScript command.
///
/// Required selector name from the command in `sdef`:
/// `handleNewWindowScriptCommand:`.
///
+ /// Accepts an optional reusable surface configuration object.
+ ///
/// Returns the newly created scripting window object.
@objc(handleNewWindowScriptCommand:)
func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> Any? {
@@ -140,7 +157,21 @@ extension NSApplication {
return nil
}
- let controller = TerminalController.newWindow(appDelegate.ghostty)
+ let baseConfig: Ghostty.SurfaceConfiguration
+ do {
+ baseConfig = try Ghostty.SurfaceConfiguration(
+ scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary
+ )
+ } catch {
+ command.scriptErrorNumber = errAECoercionFail
+ command.scriptErrorString = error.localizedDescription
+ return nil
+ }
+
+ let controller = TerminalController.newWindow(
+ appDelegate.ghostty,
+ withBaseConfig: baseConfig
+ )
let createdWindowID = ScriptWindow.stableID(primaryController: controller)
if let scriptWindow = scriptWindows.first(where: { $0.stableID == createdWindowID }) {
diff --git a/macos/Sources/Features/AppleScript/ScriptRecord.swift b/macos/Sources/Features/AppleScript/ScriptRecord.swift
new file mode 100644
index 000000000..7c81b8e29
--- /dev/null
+++ b/macos/Sources/Features/AppleScript/ScriptRecord.swift
@@ -0,0 +1,29 @@
+import Cocoa
+
+/// Protocol to more easily implement AppleScript records in Swift.
+protocol ScriptRecord {
+ /// Initialize a default record.
+ init()
+
+ /// Initialize a record from the raw value from AppleScript.
+ init(scriptRecord: NSDictionary?) throws
+
+ /// Encode into the dictionary form for AppleScript.
+ var dictionaryRepresentation: NSDictionary { get }
+}
+
+/// An error that can be thrown by `ScriptRecord.init(scriptRecord:)`. Any localized error
+/// can be thrown but this is a common one.
+enum RecordParseError: LocalizedError {
+ case invalidType(parameter: String, expected: String)
+ case invalidValue(parameter: String, message: String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidType(let parameter, let expected):
+ return "\(parameter) must be \(expected)."
+ case .invalidValue(let parameter, let message):
+ return "\(parameter) \(message)."
+ }
+ }
+}
diff --git a/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift
new file mode 100644
index 000000000..89ec85a3b
--- /dev/null
+++ b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift
@@ -0,0 +1,136 @@
+import Foundation
+
+/// AppleScript record support for `Ghostty.SurfaceConfiguration`.
+///
+/// This keeps scripting conversion at the data-structure boundary so AppleScript
+/// can pass records by value (`new surface configuration`, assign, copy, mutate)
+/// without introducing an additional wrapper type.
+extension Ghostty.SurfaceConfiguration: ScriptRecord {
+ init(scriptRecord source: NSDictionary?) throws {
+ self.init()
+
+ guard let raw = source as? [String: Any] else {
+ throw RecordParseError.invalidType(parameter: "configuration", expected: "a surface configuration record")
+ }
+
+ if let rawFontSize = raw["fontSize"] {
+ guard let number = rawFontSize as? NSNumber else {
+ throw RecordParseError.invalidType(parameter: "font size", expected: "a number")
+ }
+
+ let value = number.doubleValue
+ guard value.isFinite else {
+ throw RecordParseError.invalidValue(parameter: "font size", message: "must be a finite number")
+ }
+
+ if value < 0 {
+ throw RecordParseError.invalidValue(parameter: "font size", message: "must be a positive number")
+ }
+
+ if value > 0 {
+ fontSize = Float32(value)
+ }
+ }
+
+ if let rawWorkingDirectory = raw["workingDirectory"] {
+ guard let workingDirectory = rawWorkingDirectory as? String else {
+ throw RecordParseError.invalidType(parameter: "working directory", expected: "text")
+ }
+
+ if !workingDirectory.isEmpty {
+ self.workingDirectory = workingDirectory
+ }
+ }
+
+ if let rawCommand = raw["command"] {
+ guard let command = rawCommand as? String else {
+ throw RecordParseError.invalidType(parameter: "command", expected: "text")
+ }
+
+ if !command.isEmpty {
+ self.command = command
+ }
+ }
+
+ if let rawInitialInput = raw["initialInput"] {
+ guard let initialInput = rawInitialInput as? String else {
+ throw RecordParseError.invalidType(parameter: "initial input", expected: "text")
+ }
+
+ if !initialInput.isEmpty {
+ self.initialInput = initialInput
+ }
+ }
+
+ if let rawWaitAfterCommand = raw["waitAfterCommand"] {
+ if let boolValue = rawWaitAfterCommand as? Bool {
+ waitAfterCommand = boolValue
+ } else if let numericValue = rawWaitAfterCommand as? NSNumber {
+ waitAfterCommand = numericValue.boolValue
+ } else {
+ throw RecordParseError.invalidType(parameter: "wait after command", expected: "boolean")
+ }
+ }
+
+ if let assignments = raw["environmentVariables"] as? [String], !assignments.isEmpty {
+ environmentVariables = try Self.parseScriptEnvironmentAssignments(assignments)
+ }
+ }
+
+ var dictionaryRepresentation: NSDictionary {
+ var record: [String: Any] = [
+ "fontSize": 0,
+ "workingDirectory": "",
+ "command": "",
+ "initialInput": "",
+ "waitAfterCommand": false,
+ "environmentVariables": [String](),
+ ]
+
+ if let fontSize {
+ record["fontSize"] = NSNumber(value: fontSize)
+ }
+
+ if let workingDirectory {
+ record["workingDirectory"] = workingDirectory
+ }
+
+ if let command {
+ record["command"] = command
+ }
+
+ if let initialInput {
+ record["initialInput"] = initialInput
+ }
+
+ if waitAfterCommand {
+ record["waitAfterCommand"] = true
+ }
+
+ if !environmentVariables.isEmpty {
+ record["environmentVariables"] = environmentVariables.map { "\($0.key)=\($0.value)" }
+ }
+
+ return record as NSDictionary
+ }
+
+ private static func parseScriptEnvironmentAssignments(_ assignments: [String]) throws -> [String: String] {
+ var result: [String: String] = [:]
+
+ for assignment in assignments {
+ guard let separator = assignment.firstIndex(of: "=") else {
+ throw RecordParseError.invalidValue(
+ parameter: "environment variables",
+ message: "expected KEY=VALUE, got \"\(assignment)\""
+ )
+ }
+
+ let key = String(assignment[..