From 7ae5018fe8dbee7db7ff9da64a7ae4a11d874444 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 17 Jun 2025 20:59:12 -0700 Subject: [PATCH 01/27] macos: new terminal intent --- macos/Ghostty.xcodeproj/project.pbxproj | 16 ++++ macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../App Intents/GhosttyIntentError.swift | 9 +++ .../App Intents/NewTerminalIntent.swift | 81 +++++++++++++++++++ .../Terminal/TerminalController.swift | 2 +- 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 macos/Sources/Features/App Intents/GhosttyIntentError.swift create mode 100644 macos/Sources/Features/App Intents/NewTerminalIntent.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5c584709e..c1a7bbaef 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -120,6 +120,8 @@ A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; }; + A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; }; + A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -240,6 +242,8 @@ A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = ""; }; + A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = ""; }; + A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -299,6 +303,7 @@ A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, + A5E4082C2E0237270035FEAC /* App Intents */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, A58636622DEF955100E04A10 /* Splits */, @@ -598,6 +603,15 @@ path = ClipboardConfirmation; sourceTree = ""; }; + A5E4082C2E0237270035FEAC /* App Intents */ = { + isa = PBXGroup; + children = ( + A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, + A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, + ); + path = "App Intents"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -777,8 +791,10 @@ A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, + A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, + A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index c56d7c3ac..7336f18d6 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -167,7 +167,7 @@ class AppDelegate: NSObject, // This registers the Ghostty => Services menu to exist. NSApp.servicesMenu = menuServices - + // Setup a local event monitor for app-level keyboard shortcuts. See // localEventHandler for more info why. _ = NSEvent.addLocalMonitorForEvents( diff --git a/macos/Sources/Features/App Intents/GhosttyIntentError.swift b/macos/Sources/Features/App Intents/GhosttyIntentError.swift new file mode 100644 index 000000000..a04db1e6f --- /dev/null +++ b/macos/Sources/Features/App Intents/GhosttyIntentError.swift @@ -0,0 +1,9 @@ +enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible { + case appUnavailable + + var localizedStringResource: LocalizedStringResource { + switch self { + case .appUnavailable: return "The Ghostty app isn't properly initialized." + } + } +} diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift new file mode 100644 index 000000000..c54d31c09 --- /dev/null +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -0,0 +1,81 @@ +import AppKit +import AppIntents + +/// App intent that allows creating a new terminal window or tab. +/// +/// This requires macOS 15 or greater because we use features of macOS 15 here. +@available(macOS 15.0, *) +struct NewTerminalIntent: AppIntent { + static var title: LocalizedStringResource = "New Terminal" + static var description = IntentDescription("Create a new terminal.") + + @Parameter( + title: "Location", + description: "The location that the terminal should be created.", + default: .window + ) + var location: NewTerminalLocation + + @Parameter( + title: "Working Directory", + description: "The working directory to open in the terminal.", + supportedContentTypes: [.folder] + ) + var workingDirectory: IntentFile? + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .foreground(.immediate) + + @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") + static var openAppWhenRun = true + + static var parameterSummary: some ParameterSummary { + Summary("New Terminal \(\.$location)") + } + + @MainActor + func perform() async throws -> some IntentResult { + guard let appDelegate = NSApp.delegate as? AppDelegate else { + throw GhosttyIntentError.appUnavailable + } + + var config = Ghostty.SurfaceConfiguration() + + // If we were given a working directory then open that directory + if let url = workingDirectory?.fileURL { + let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent() + config.workingDirectory = dir.path(percentEncoded: false) + } + + switch location { + case .window: + _ = TerminalController.newWindow( + appDelegate.ghostty, + withBaseConfig: config) + + case .tab: + _ = TerminalController.newTab( + appDelegate.ghostty, + from: TerminalController.preferredParent?.window, + withBaseConfig: config) + } + + return .result() + } +} + +// MARK: NewTerminalLocation + +enum NewTerminalLocation: String { + case tab + case window +} + +extension NewTerminalLocation: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Location") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .tab: .init(title: "Tab"), + .window: .init(title: "Window"), + ] +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 273744237..a224c9248 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -169,7 +169,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr private static var lastCascadePoint = NSPoint(x: 0, y: 0) // The preferred parent terminal controller. - private static var preferredParent: TerminalController? { + static var preferredParent: TerminalController? { all.first { $0.window?.isMainWindow ?? false } ?? all.last From 2aa731a64e13856f02f0484dfce403a855abb723 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jun 2025 08:30:41 -0700 Subject: [PATCH 02/27] macos: TerminalEntity --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../Features/App Intents/TerminalEntity.swift | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 macos/Sources/Features/App Intents/TerminalEntity.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index c1a7bbaef..913ce1995 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; }; A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; }; A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; }; + A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -244,6 +245,7 @@ A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = ""; }; A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = ""; }; A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = ""; }; + A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -606,6 +608,7 @@ A5E4082C2E0237270035FEAC /* App Intents */ = { isa = PBXGroup; children = ( + A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */, A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, ); @@ -753,6 +756,7 @@ A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, + A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/TerminalEntity.swift b/macos/Sources/Features/App Intents/TerminalEntity.swift new file mode 100644 index 000000000..dabfa25ad --- /dev/null +++ b/macos/Sources/Features/App Intents/TerminalEntity.swift @@ -0,0 +1,67 @@ +import AppKit +import AppIntents + +struct TerminalEntity: AppEntity { + let id: UUID + + @Property(title: "Title") + var title: String + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Terminal") + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)") + } + + static var defaultQuery = TerminalQuery() + + init(_ view: Ghostty.SurfaceView) { + self.id = view.uuid + self.title = view.title + } +} + +struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { + @MainActor + func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] { + return all.filter { + identifiers.contains($0.uuid) + }.map { + TerminalEntity($0) + } + } + + @MainActor + func entities(matching string: String) async throws -> [TerminalEntity] { + return all.filter { + $0.title.localizedCaseInsensitiveContains(string) + }.map { + TerminalEntity($0) + } + } + + @MainActor + func allEntities() async throws -> [TerminalEntity] { + return all.map { TerminalEntity($0) } + } + + @MainActor + func suggestedEntities() async throws -> [TerminalEntity] { + return try await allEntities() + } + + @MainActor + private var all: [Ghostty.SurfaceView] { + // Find all of our terminal windows (includes quick terminal) + let controllers = NSApp.windows.compactMap { + $0.windowController as? BaseTerminalController + } + + // Get all our surfaces + return controllers.reduce([]) { result, c in + result + (c.surfaceTree.root?.leaves() ?? []) + } + } +} From 93f0ee2089c4642813d9002faa100a592027643f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jun 2025 10:39:15 -0700 Subject: [PATCH 03/27] macos: GetTerminalDetails intent --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../GetTerminalDetailsIntent.swift | 65 +++++++++++++++++++ .../Features/App Intents/TerminalEntity.swift | 26 +++++++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 26 +++++++- .../Helpers/Extensions/NSView+Extension.swift | 19 ++++++ 5 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 913ce1995..7bac50670 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; }; A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; }; A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; }; + A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -246,6 +247,7 @@ A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = ""; }; A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = ""; }; A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = ""; }; + A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -610,6 +612,7 @@ children = ( A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */, A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, + A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, ); path = "App Intents"; @@ -754,6 +757,7 @@ C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, + A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift new file mode 100644 index 000000000..a57ad3ac4 --- /dev/null +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -0,0 +1,65 @@ +import AppKit +import AppIntents + +/// App intent that retrieves details about a specific terminal. +struct GetTerminalDetailsIntent: AppIntent { + static var title: LocalizedStringResource = "Get Details of Terminal" + + @Parameter( + title: "Detail", + description: "The detail to extract about a terminal." + ) + var detail: TerminalDetail + + @Parameter( + title: "Terminal", + description: "The terminal to extract information about." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + static var parameterSummary: some ParameterSummary { + Summary("Get \(\.$detail) from \(\.$terminal)") + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + switch detail { + case .title: return .result(value: terminal.title) + case .workingDirectory: return .result(value: terminal.workingDirectory) + case .allContents: + guard let view = terminal.surfaceView else { return .result(value: nil) } + return .result(value: view.cachedScreenContents.get()) + case .selectedText: + guard let view = terminal.surfaceView else { return .result(value: nil) } + return .result(value: view.accessibilitySelectedText()) + case .visibleText: + guard let view = terminal.surfaceView else { return .result(value: nil) } + return .result(value: view.cachedVisibleContents.get()) + } + } +} + +// MARK: TerminalDetail + +enum TerminalDetail: String { + case title + case workingDirectory + case allContents + case selectedText + case visibleText +} + +extension TerminalDetail: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .title: .init(title: "Title"), + .workingDirectory: .init(title: "Working Directory"), + .allContents: .init(title: "Full Contents"), + .selectedText: .init(title: "Selected Text"), + .visibleText: .init(title: "Visible Text"), + ] +} diff --git a/macos/Sources/Features/App Intents/TerminalEntity.swift b/macos/Sources/Features/App Intents/TerminalEntity.swift index dabfa25ad..0d2832bf5 100644 --- a/macos/Sources/Features/App Intents/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/TerminalEntity.swift @@ -1,5 +1,6 @@ import AppKit import AppIntents +import SwiftUI struct TerminalEntity: AppEntity { let id: UUID @@ -7,12 +8,31 @@ struct TerminalEntity: AppEntity { @Property(title: "Title") var title: String + @Property(title: "Working Directory") + var workingDirectory: String? + + var screenshot: Image? + static var typeDisplayRepresentation: TypeDisplayRepresentation { TypeDisplayRepresentation(name: "Terminal") } + @MainActor var displayRepresentation: DisplayRepresentation { - DisplayRepresentation(title: "\(title)") + var rep = DisplayRepresentation(title: "\(title)") + if let screenshot, + let nsImage = ImageRenderer(content: screenshot).nsImage, + let data = nsImage.tiffRepresentation { + rep.image = .init(data: data) + } + + return rep + } + + /// Returns the view associated with this entity. This may no longer exist. + @MainActor + var surfaceView: Ghostty.SurfaceView? { + Self.defaultQuery.all.first { $0.uuid == self.id } } static var defaultQuery = TerminalQuery() @@ -20,6 +40,8 @@ struct TerminalEntity: AppEntity { init(_ view: Ghostty.SurfaceView) { self.id = view.uuid self.title = view.title + self.workingDirectory = view.pwd + self.screenshot = view.screenshot() } } @@ -53,7 +75,7 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { } @MainActor - private var all: [Ghostty.SurfaceView] { + var all: [Ghostty.SurfaceView] { // Find all of our terminal windows (includes quick terminal) let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index a47dbdaca..131e39ba7 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -139,7 +139,8 @@ extension Ghostty { private var titleFromTerminal: String? // The cached contents of the screen. - private var cachedScreenContents: CachedValue + private(set) var cachedScreenContents: CachedValue + private(set) var cachedVisibleContents: CachedValue /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil @@ -166,6 +167,7 @@ extension Ghostty { // it back up later so we can reference `self`. This is a hack we should // fix at some point. self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" } + self.cachedVisibleContents = self.cachedScreenContents // Initialize with some default frame size. The important thing is that this // is non-zero so that our layer bounds are non-zero so that our renderer @@ -193,6 +195,26 @@ extension Ghostty { defer { ghostty_surface_free_text(surface, &text) } return String(cString: text.text) } + cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in + guard let self else { return "" } + guard let surface = self.surface else { return "" } + var text = ghostty_text_s() + let sel = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0), + rectangle: false) + guard ghostty_surface_read_text(surface, sel, &text) else { return "" } + defer { ghostty_surface_free_text(surface, &text) } + return String(cString: text.text) + } // Set a timer to show the ghost emoji after 500ms if no title is set titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in @@ -1979,7 +2001,7 @@ extension Ghostty.SurfaceView { /// Caches a value for some period of time, evicting it automatically when that time expires. /// We use this to cache our surface content. This probably should be extracted some day /// to a more generic helper. -fileprivate class CachedValue { +class CachedValue { private var value: T? private let fetch: () -> T private let duration: Duration diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift index b3628d406..fb209e4ac 100644 --- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI extension NSView { /// Returns true if this view is currently in the responder chain @@ -15,6 +16,24 @@ extension NSView { } } +// MARK: Screenshot + +extension NSView { + /// Take a screenshot of just this view. + func screenshot() -> NSImage? { + guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + cacheDisplay(in: bounds, to: bitmapRep) + let image = NSImage(size: bounds.size) + image.addRepresentation(bitmapRep) + return image + } + + func screenshot() -> Image? { + guard let nsImage: NSImage = self.screenshot() else { return nil } + return Image(nsImage: nsImage) + } +} + // MARK: View Traversal and Search extension NSView { From e51a93ee7cb859cf3c312293282dde31a0cc55d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jun 2025 11:14:47 -0700 Subject: [PATCH 04/27] macos: Terminal entity has screen contents deferred --- .../Features/App Intents/TerminalEntity.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/macos/Sources/Features/App Intents/TerminalEntity.swift b/macos/Sources/Features/App Intents/TerminalEntity.swift index 0d2832bf5..3aea691fe 100644 --- a/macos/Sources/Features/App Intents/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/TerminalEntity.swift @@ -11,6 +11,26 @@ struct TerminalEntity: AppEntity { @Property(title: "Working Directory") var workingDirectory: String? + @MainActor + @DeferredProperty(title: "Full Contents") + @available(macOS 26.0, *) + var screenContents: String? { + get async { + guard let surfaceView else { return nil } + return surfaceView.cachedScreenContents.get() + } + } + + @MainActor + @DeferredProperty(title: "Visible Contents") + @available(macOS 26.0, *) + var visibleContents: String? { + get async { + guard let surfaceView else { return nil } + return surfaceView.cachedVisibleContents.get() + } + } + var screenshot: Image? static var typeDisplayRepresentation: TypeDisplayRepresentation { From b8d44637547cf8041f490e9fac4931d33ff51900 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jun 2025 11:37:11 -0700 Subject: [PATCH 05/27] macos: terminal not found should be an error --- .../Features/App Intents/GetTerminalDetailsIntent.swift | 6 +++--- macos/Sources/Features/App Intents/GhosttyIntentError.swift | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift index a57ad3ac4..5c41908f4 100644 --- a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -30,13 +30,13 @@ struct GetTerminalDetailsIntent: AppIntent { case .title: return .result(value: terminal.title) case .workingDirectory: return .result(value: terminal.workingDirectory) case .allContents: - guard let view = terminal.surfaceView else { return .result(value: nil) } + guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } return .result(value: view.cachedScreenContents.get()) case .selectedText: - guard let view = terminal.surfaceView else { return .result(value: nil) } + guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } return .result(value: view.accessibilitySelectedText()) case .visibleText: - guard let view = terminal.surfaceView else { return .result(value: nil) } + guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } return .result(value: view.cachedVisibleContents.get()) } } diff --git a/macos/Sources/Features/App Intents/GhosttyIntentError.swift b/macos/Sources/Features/App Intents/GhosttyIntentError.swift index a04db1e6f..34a0636d9 100644 --- a/macos/Sources/Features/App Intents/GhosttyIntentError.swift +++ b/macos/Sources/Features/App Intents/GhosttyIntentError.swift @@ -1,9 +1,11 @@ enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible { case appUnavailable + case surfaceNotFound var localizedStringResource: LocalizedStringResource { switch self { case .appUnavailable: return "The Ghostty app isn't properly initialized." + case .surfaceNotFound: return "The terminal no longer exists." } } } From 683b38f62ca4032bb3c0880c3857b70c6707fd91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jun 2025 19:37:41 -0700 Subject: [PATCH 06/27] macos: can specify parent terminal for new terminal intent --- .../App Intents/NewTerminalIntent.swift | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index c54d31c09..51b037cca 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -23,16 +23,18 @@ struct NewTerminalIntent: AppIntent { ) var workingDirectory: IntentFile? + @Parameter( + title: "Parent Terminal", + description: "The terminal to inherit the base configuration from." + ) + var parent: TerminalEntity? + @available(macOS 26.0, *) static var supportedModes: IntentModes = .foreground(.immediate) @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") static var openAppWhenRun = true - static var parameterSummary: some ParameterSummary { - Summary("New Terminal \(\.$location)") - } - @MainActor func perform() async throws -> some IntentResult { guard let appDelegate = NSApp.delegate as? AppDelegate else { @@ -47,16 +49,31 @@ struct NewTerminalIntent: AppIntent { config.workingDirectory = dir.path(percentEncoded: false) } + // Determine if we have a parent and get it + let parent: Ghostty.SurfaceView? + if let parentParam = self.parent { + guard let view = parentParam.surfaceView else { + throw GhosttyIntentError.surfaceNotFound + } + + parent = view + } else if let preferred = TerminalController.preferredParent { + parent = preferred.focusedSurface ?? preferred.surfaceTree.root?.leftmostLeaf() + } else { + parent = nil + } + switch location { case .window: _ = TerminalController.newWindow( appDelegate.ghostty, - withBaseConfig: config) + withBaseConfig: config, + withParent: parent?.window) case .tab: _ = TerminalController.newTab( appDelegate.ghostty, - from: TerminalController.preferredParent?.window, + from: parent?.window, withBaseConfig: config) } From bbb69c8f27273247cc8e838aef5075cc258575d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 18 Jun 2025 19:50:05 -0700 Subject: [PATCH 07/27] macos: NewTerminalIntent returns Terminal, can split --- .../App Intents/NewTerminalIntent.swift | 58 +++++++++++++++-- .../Terminal/BaseTerminalController.swift | 65 ++++++++++++------- 2 files changed, 93 insertions(+), 30 deletions(-) diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 51b037cca..55f33bd46 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -1,5 +1,6 @@ import AppKit import AppIntents +import GhosttyKit /// App intent that allows creating a new terminal window or tab. /// @@ -16,6 +17,12 @@ struct NewTerminalIntent: AppIntent { ) var location: NewTerminalLocation + @Parameter( + title: "Command", + description: "Command to execute instead of the default shell." + ) + var command: String? + @Parameter( title: "Working Directory", description: "The working directory to open in the terminal.", @@ -36,12 +43,14 @@ struct NewTerminalIntent: AppIntent { static var openAppWhenRun = true @MainActor - func perform() async throws -> some IntentResult { + func perform() async throws -> some IntentResult & ReturnsValue { guard let appDelegate = NSApp.delegate as? AppDelegate else { throw GhosttyIntentError.appUnavailable } + let ghostty = appDelegate.ghostty var config = Ghostty.SurfaceConfiguration() + config.command = command // If we were given a working directory then open that directory if let url = workingDirectory?.fileURL { @@ -65,19 +74,38 @@ struct NewTerminalIntent: AppIntent { switch location { case .window: - _ = TerminalController.newWindow( - appDelegate.ghostty, + let newController = TerminalController.newWindow( + ghostty, withBaseConfig: config, withParent: parent?.window) + if let view = newController.surfaceTree.root?.leftmostLeaf() { + return .result(value: TerminalEntity(view)) + } case .tab: - _ = TerminalController.newTab( - appDelegate.ghostty, + let newController = TerminalController.newTab( + ghostty, from: parent?.window, withBaseConfig: config) + if let view = newController?.surfaceTree.root?.leftmostLeaf() { + return .result(value: TerminalEntity(view)) + } + + case .splitLeft, .splitRight, .splitUp, .splitDown: + guard let parent, + let controller = parent.window?.windowController as? BaseTerminalController else { + throw GhosttyIntentError.surfaceNotFound + } + + if let view = controller.newSplit( + at: parent, + direction: location.splitDirection! + ) { + return .result(value: TerminalEntity(view)) + } } - return .result() + return .result(value: .none) } } @@ -86,6 +114,20 @@ struct NewTerminalIntent: AppIntent { enum NewTerminalLocation: String { case tab case window + case splitLeft = "split:left" + case splitRight = "split:right" + case splitUp = "split:up" + case splitDown = "split:down" + + var splitDirection: SplitTree.NewDirection? { + switch self { + case .splitLeft: return .left + case .splitRight: return .right + case .splitUp: return .up + case .splitDown: return .down + default: return nil + } + } } extension NewTerminalLocation: AppEnum { @@ -94,5 +136,9 @@ extension NewTerminalLocation: AppEnum { static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ .tab: .init(title: "Tab"), .window: .init(title: "Window"), + .splitLeft: .init(title: "Split Left"), + .splitRight: .init(title: "Split Right"), + .splitUp: .init(title: "Split Up"), + .splitDown: .init(title: "Split Down"), ] } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index bc91b920e..81b7d32b6 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -193,6 +193,46 @@ class BaseTerminalController: NSWindowController, } } + // MARK: Methods + + /// Create a new split. + @discardableResult + func newSplit( + at oldView: Ghostty.SurfaceView, + direction: SplitTree.NewDirection, + baseConfig config: Ghostty.SurfaceConfiguration? = nil + ) -> Ghostty.SurfaceView? { + // We can only create new splits for surfaces in our tree. + guard surfaceTree.root?.node(view: oldView) != nil else { return nil } + + // Create a new surface view + guard let ghostty_app = ghostty.app else { return nil } + let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) + + // Do the split + let newTree: SplitTree + do { + newTree = try surfaceTree.insert( + view: newView, + at: oldView, + direction: direction) + } catch { + // If splitting fails for any reason (it should not), then we just log + // and return. The new view we created will be deinitialized and its + // no big deal. + Ghostty.logger.warning("failed to insert split: \(error)") + return nil + } + + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Split") + + return newView + } + /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. @@ -477,30 +517,7 @@ class BaseTerminalController: NSWindowController, default: return } - // Create a new surface view - guard let ghostty_app = ghostty.app else { return } - let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) - - // Do the split - let newTree: SplitTree - do { - newTree = try surfaceTree.insert( - view: newView, - at: oldView, - direction: splitDirection) - } catch { - // If splitting fails for any reason (it should not), then we just log - // and return. The new view we created will be deinitialized and its - // no big deal. - Ghostty.logger.warning("failed to insert split: \(error)") - return - } - - replaceSurfaceTree( - newTree, - moveFocusTo: newView, - moveFocusFrom: oldView, - undoAction: "New Split") + newSplit(at: oldView, direction: splitDirection, baseConfig: config) } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { From 5259d0fa55e8f60db8632cf47dab98744eb730ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 07:07:32 -0700 Subject: [PATCH 08/27] macos: starting to work on new libghostty data models --- macos/Ghostty.xcodeproj/project.pbxproj | 16 +++-- .../TerminalCommandPalette.swift | 38 ++++------- macos/Sources/Ghostty/AppError.swift | 3 - macos/Sources/Ghostty/Ghostty.Command.swift | 46 +++++++++++++ macos/Sources/Ghostty/Ghostty.Error.swift | 12 ++++ macos/Sources/Ghostty/Ghostty.Surface.swift | 64 +++++++++++++++++++ macos/Sources/Ghostty/Package.swift | 9 +++ macos/Sources/Ghostty/SurfaceView.swift | 4 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 21 +++--- src/apprt/embedded.zig | 4 +- 10 files changed, 171 insertions(+), 46 deletions(-) delete mode 100644 macos/Sources/Ghostty/AppError.swift create mode 100644 macos/Sources/Ghostty/Ghostty.Command.swift create mode 100644 macos/Sources/Ghostty/Ghostty.Error.swift create mode 100644 macos/Sources/Ghostty/Ghostty.Surface.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 7bac50670..db2c9d893 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -53,7 +53,6 @@ A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; }; A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; - A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; @@ -124,6 +123,9 @@ A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; }; A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; }; A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; }; + A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; }; + A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; }; + A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -175,7 +177,6 @@ A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = ""; }; A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; - A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = ""; }; @@ -248,6 +249,9 @@ A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = ""; }; A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = ""; }; A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = ""; }; + A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = ""; }; + A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = ""; }; + A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -440,12 +444,14 @@ A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, + A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, + A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */, A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A55685DF29A03A9F004303CE /* AppError.swift */, + A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, ); @@ -766,6 +772,7 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */, A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, @@ -800,6 +807,7 @@ A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */, + A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */, @@ -809,13 +817,13 @@ A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, + A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, - A55685E029A03A9F004303CE /* AppError.swift in Sources */, A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 47f2baf23..d02828494 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -17,33 +17,19 @@ struct TerminalCommandPaletteView: View { // The commands available to the command palette. private var commandOptions: [CommandOption] { - guard let surface = surfaceView.surface else { return [] } - - var ptr: UnsafeMutablePointer? = nil - var count: Int = 0 - ghostty_surface_commands(surface, &ptr, &count) - guard let ptr else { return [] } - - let buffer = UnsafeBufferPointer(start: ptr, count: count) - return Array(buffer).filter { c in - let key = String(cString: c.action_key) - switch (key) { - case "toggle_tab_overview", - "toggle_window_decorations", - "show_gtk_inspector": - return false - default: - return true - } - }.map { c in - let action = String(cString: c.action) - return CommandOption( - title: String(cString: c.title), - description: String(cString: c.description), - symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList - ) { - onAction(action) + guard let surface = surfaceView.surfaceModel else { return [] } + do { + return try surface.commands().map { c in + return CommandOption( + title: c.title, + description: c.description, + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList + ) { + onAction(c.action) + } } + } catch { + return [] } } diff --git a/macos/Sources/Ghostty/AppError.swift b/macos/Sources/Ghostty/AppError.swift deleted file mode 100644 index 55f191d3d..000000000 --- a/macos/Sources/Ghostty/AppError.swift +++ /dev/null @@ -1,3 +0,0 @@ -enum AppError: Error { - case surfaceCreateError -} diff --git a/macos/Sources/Ghostty/Ghostty.Command.swift b/macos/Sources/Ghostty/Ghostty.Command.swift new file mode 100644 index 000000000..1479ae92d --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Command.swift @@ -0,0 +1,46 @@ +import GhosttyKit + +extension Ghostty { + /// `ghostty_command_s` + struct Command: Sendable { + private let cValue: ghostty_command_s + + /// The title of the command. + var title: String { + String(cString: cValue.title) + } + + /// Human-friendly description of what this command will do. + var description: String { + String(cString: cValue.description) + } + + /// The full action that must be performed to invoke this command. + var action: String { + String(cString: cValue.action) + } + + /// Only the key portion of the action so you can compare action types, e.g. `goto_split` + /// instead of `goto_split:left`. + var actionKey: String { + String(cString: cValue.action_key) + } + + /// True if this can be performed on this target. + var isSupported: Bool { + !Self.unsupportedActionKeys.contains(actionKey) + } + + /// Unsupported action keys, because they either don't make sense in the context of our + /// target platform or they just aren't implemented yet. + static let unsupportedActionKeys: [String] = [ + "toggle_tab_overview", + "toggle_window_decorations", + "show_gtk_inspector", + ] + + init(cValue: ghostty_command_s) { + self.cValue = cValue + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Error.swift b/macos/Sources/Ghostty/Ghostty.Error.swift new file mode 100644 index 000000000..66f6857bf --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Error.swift @@ -0,0 +1,12 @@ +extension Ghostty { + /// Possible errors from internal Ghostty calls. + enum Error: Swift.Error, CustomLocalizedStringResourceConvertible { + case apiFailed + + var localizedStringResource: LocalizedStringResource { + switch self { + case .apiFailed: return "libghostty API call failed" + } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift new file mode 100644 index 000000000..5560ff3a8 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -0,0 +1,64 @@ +import GhosttyKit + +extension Ghostty { + /// Represents a single surface within Ghostty. + /// + /// NOTE(mitchellh): This is a work-in-progress class as part of a general refactor + /// of our Ghostty data model. At the time of writing there's still a ton of surface + /// functionality that is not encapsulated in this class. It is planned to migrate that + /// all over. + /// + /// Wraps a `ghostty_surface_t` + final class Surface: Sendable { + private let surface: ghostty_surface_t + + /// Read the underlying C value for this surface. This is unsafe because the value will be + /// freed when the Surface class is deinitialized. + var unsafeCValue: ghostty_surface_t { + surface + } + + /// Initialize from the C structure. + init(cSurface: ghostty_surface_t) { + self.surface = cSurface + } + + deinit { + // deinit is not guaranteed to happen on the main actor and our API + // calls into libghostty must happen there so we capture the surface + // value so we don't capture `self` and then we detach it in a task. + // We can't wait for the task to succeed so this will happen sometime + // but that's okay. + let surface = self.surface + Task.detached { @MainActor in + ghostty_surface_free(surface) + } + } + + /// Perform a keybinding action. + /// + /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` + /// you can perform `goto_tab:4` with this. + /// + /// Returns true if the action was performed. Invalid actions return false. + @MainActor + func perform(action: String) -> Bool { + let len = action.utf8CString.count + if (len == 0) { return false } + return action.withCString { cString in + ghostty_surface_binding_action(surface, cString, UInt(len - 1)) + } + } + + /// Command options for this surface. + @MainActor + func commands() throws -> [Command] { + var ptr: UnsafeMutablePointer? = nil + var count: Int = 0 + ghostty_surface_commands(surface, &ptr, &count) + guard let ptr else { throw Error.apiFailed } + let buffer = UnsafeBufferPointer(start: ptr, count: count) + return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported } + } + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 82721c17e..125a09825 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -19,6 +19,15 @@ struct Ghostty { static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" } +// MARK: C Extensions + +/// A command is fully self-contained so it is Sendable. +extension ghostty_command_s: @unchecked @retroactive Sendable {} + +/// A surface is sendable because it is just a reference type. Using the surface in parameters +/// may be unsafe but the value itself is safe to send across threads. +extension ghostty_surface_t: @unchecked @retroactive Sendable {} + // MARK: Build Info extension Ghostty { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index f830da4ef..371e4ff41 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -79,7 +79,7 @@ extension Ghostty { let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) #endif - Surface(view: surfaceView, size: geo.size) + SurfaceRepresentable(view: surfaceView, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfaceView, surfaceView) @@ -381,7 +381,7 @@ extension Ghostty { /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. - struct Surface: OSViewRepresentable { + struct SurfaceRepresentable: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 131e39ba7..fe0851261 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -115,10 +115,20 @@ extension Ghostty { } } + /// Returns the data model for this surface. + /// + /// Note: eventually, all surface access will be through this, but presently its in a transition + /// state so we're mixing this with direct surface access. + private(set) var surfaceModel: Ghostty.Surface? + + /// Returns the underlying C value for the surface. See "note" on surfaceModel. + var surface: ghostty_surface_t? { + surfaceModel?.unsafeCValue + } + // Notification identifiers associated with this surface var notificationIdentifiers: Set = [] - private(set) var surface: ghostty_surface_t? private var markedText: NSMutableAttributedString private(set) var focused: Bool = true private var prevPressureStage: Int = 0 @@ -282,10 +292,10 @@ extension Ghostty { let surface_cfg = baseConfig ?? SurfaceConfiguration() var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { - self.error = AppError.surfaceCreateError + self.error = Ghostty.Error.apiFailed return } - self.surface = surface; + self.surfaceModel = Ghostty.Surface(cSurface: surface) // Setup our tracking area so we get mouse moved events updateTrackingAreas() @@ -340,11 +350,6 @@ extension Ghostty { // Remove any notifications associated with this surface let identifiers = Array(self.notificationIdentifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - - // Free our core surface resources - if let surface = self.surface { - ghostty_surface_free(surface) - } } func focusDidChange(_ focused: Bool) { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a61c75e96..01e287d16 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1837,12 +1837,10 @@ pub const CAPI = struct { return false; }; - _ = ptr.core_surface.performBindingAction(action) catch |err| { + return ptr.core_surface.performBindingAction(action) catch |err| { log.err("error performing binding action action={} err={}", .{ action, err }); return false; }; - - return true; } /// Complete a clipboard read request started via the read callback. From 14e46d09791bed1332b93f050a58eb69d9c8350b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 09:43:25 -0700 Subject: [PATCH 09/27] macos: InvokeCommandPaletteIntent and CommandEntity --- macos/Ghostty.xcodeproj/project.pbxproj | 18 ++- .../App Intents/CommandPaletteIntent.swift | 34 +++++ .../App Intents/Entities/CommandEntity.swift | 128 ++++++++++++++++++ .../{ => Entities}/TerminalEntity.swift | 5 + 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Features/App Intents/CommandPaletteIntent.swift create mode 100644 macos/Sources/Features/App Intents/Entities/CommandEntity.swift rename macos/Sources/Features/App Intents/{ => Entities}/TerminalEntity.swift (96%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index db2c9d893..990280397 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -126,6 +126,8 @@ A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; }; A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; }; A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; }; + A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; }; + A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -252,6 +254,8 @@ A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = ""; }; A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = ""; }; A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = ""; }; + A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = ""; }; + A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -616,14 +620,24 @@ A5E4082C2E0237270035FEAC /* App Intents */ = { isa = PBXGroup; children = ( - A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */, + A5E408412E0453370035FEAC /* Entities */, A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, + A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */, A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, ); path = "App Intents"; sourceTree = ""; }; + A5E408412E0453370035FEAC /* Entities */ = { + isa = PBXGroup; + children = ( + A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */, + A5E4083F2E04532A0035FEAC /* CommandEntity.swift */, + ); + path = Entities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -750,6 +764,7 @@ buildActionMask = 2147483647; files = ( A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */, + A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, @@ -827,6 +842,7 @@ A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, + A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */, A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift new file mode 100644 index 000000000..2c1ff3386 --- /dev/null +++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift @@ -0,0 +1,34 @@ +import AppKit +import AppIntents + +/// App intent that invokes a command palette entry. +@available(macOS 14.0, *) +struct CommandPaletteIntent: AppIntent { + static var title: LocalizedStringResource = "Invoke Command Palette Action" + + @Parameter( + title: "Terminal", + description: "The terminal to base available commands from." + ) + var terminal: TerminalEntity + + @Parameter( + title: "Command", + description: "The command to invoke.", + optionsProvider: CommandQuery() + ) + var command: CommandEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + let performed = surface.perform(action: command.action) + return .result(value: performed) + } +} diff --git a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift new file mode 100644 index 000000000..f7abcc6de --- /dev/null +++ b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift @@ -0,0 +1,128 @@ +import AppIntents + +// MARK: AppEntity + +@available(macOS 14.0, *) +struct CommandEntity: AppEntity { + let id: ID + + // Note: for macOS 26 we can move all the properties to @ComputedProperty. + + @Property(title: "Title") + var title: String + + @Property(title: "Description") + var description: String + + @Property(title: "Action") + var action: String + + /// The underlying data model + let command: Ghostty.Command + + /// A command identifier is a composite key based on the terminal and action. + struct ID: Hashable { + let terminalId: TerminalEntity.ID + let actionKey: String + + init(terminalId: TerminalEntity.ID, actionKey: String) { + self.terminalId = terminalId + self.actionKey = actionKey + } + } + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Command Palette Command") + } + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation( + title: LocalizedStringResource(stringLiteral: command.title), + subtitle: LocalizedStringResource(stringLiteral: command.description), + ) + } + + static var defaultQuery = CommandQuery() + + init(_ command: Ghostty.Command, for terminal: TerminalEntity) { + self.id = .init(terminalId: terminal.id, actionKey: command.actionKey) + self.command = command + self.title = command.title + self.description = command.description + self.action = command.action + } +} + +@available(macOS 14.0, *) +extension CommandEntity.ID: RawRepresentable { + var rawValue: String { + return "\(terminalId):\(actionKey)" + } + + init?(rawValue: String) { + let components = rawValue.split(separator: ":", maxSplits: 1) + guard components.count == 2 else { return nil } + + guard let terminalId = TerminalEntity.ID(uuidString: String(components[0])) else { + return nil + } + + self.terminalId = terminalId + self.actionKey = String(components[1]) + } +} + +// Required by AppEntity +@available(macOS 14.0, *) +extension CommandEntity.ID: EntityIdentifierConvertible { + static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? { + .init(rawValue: entityIdentifierString) + } + + var entityIdentifierString: String { + rawValue + } +} + +// MARK: EntityQuery + +@available(macOS 14.0, *) +struct CommandQuery: EntityQuery { + // Inject our terminal parameter from our command palette intent. + @IntentParameterDependency(\.$terminal) + var commandPaletteIntent + + @MainActor + func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] { + // Extract unique terminal IDs to avoid fetching duplicates + let terminalIds = Set(identifiers.map(\.terminalId)) + let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds)) + + // Build a cache of terminals and their available commands + // This avoids repeated command fetching for the same terminal + typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command]) + let commandMap: [TerminalEntity.ID: Tuple] = + terminals.reduce(into: [:]) { result, terminal in + guard let commands = try? terminal.surfaceModel?.commands() else { return } + result[terminal.id] = (terminal: terminal, commands: commands) + } + + // Map each identifier to its corresponding CommandEntity. If a command doesn't + // exist it maps to nil and is removed via compactMap. + return identifiers.compactMap { id in + guard let (terminal, commands) = commandMap[id.terminalId], + let command = commands.first(where: { $0.actionKey == id.actionKey }) else { + return nil + } + + return CommandEntity(command, for: terminal) + } + } + + @MainActor + func suggestedEntities() async throws -> [CommandEntity] { + guard let terminal = commandPaletteIntent?.terminal, + let surface = terminal.surfaceModel else { return [] } + return try surface.commands().map { CommandEntity($0, for: terminal) } + } +} diff --git a/macos/Sources/Features/App Intents/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift similarity index 96% rename from macos/Sources/Features/App Intents/TerminalEntity.swift rename to macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index 3aea691fe..750512d02 100644 --- a/macos/Sources/Features/App Intents/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -55,6 +55,11 @@ struct TerminalEntity: AppEntity { Self.defaultQuery.all.first { $0.uuid == self.id } } + @MainActor + var surfaceModel: Ghostty.Surface? { + surfaceView?.surfaceModel + } + static var defaultQuery = TerminalQuery() init(_ view: Ghostty.SurfaceView) { From c904e86883a8567d96ab6655d28365557d77c57f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 10:47:56 -0700 Subject: [PATCH 10/27] macos: invoke keybind intent --- macos/Ghostty.xcodeproj/project.pbxproj | 4 +++ .../Features/App Intents/KeybindIntent.swift | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 macos/Sources/Features/App Intents/KeybindIntent.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 990280397..a691ce55f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -128,6 +128,7 @@ A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; }; A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; }; A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; }; + A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -256,6 +257,7 @@ A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = ""; }; A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = ""; }; A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = ""; }; + A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -624,6 +626,7 @@ A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */, + A5E408442E0483F80035FEAC /* KeybindIntent.swift */, A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, ); path = "App Intents"; @@ -823,6 +826,7 @@ A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */, A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */, + A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift new file mode 100644 index 000000000..ddb9c489c --- /dev/null +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -0,0 +1,32 @@ +import AppKit +import AppIntents + +/// App intent that invokes a command palette entry. +struct KeybindIntent: AppIntent { + static var title: LocalizedStringResource = "Invoke a Keybind Action" + + @Parameter( + title: "Terminal", + description: "The terminal to base available commands from." + ) + var terminal: TerminalEntity + + @Parameter( + title: "Action", + description: "The keybind action to invoke. This can be any valid keybind action you could put in a configuration file." + ) + var action: String + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + let performed = surface.perform(action: action) + return .result(value: performed) + } +} From a6074040e7f9268c84ce44bd5bbe0d072548b9ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 11:07:46 -0700 Subject: [PATCH 11/27] macos: input intent --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../Features/App Intents/InputIntent.swift | 92 +++++++++++++++++++ .../Features/App Intents/KeybindIntent.swift | 3 +- macos/Sources/Ghostty/Ghostty.Input.swift | 11 +++ macos/Sources/Ghostty/Ghostty.Surface.swift | 13 +++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 10 +- 6 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Features/App Intents/InputIntent.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a691ce55f..bbb34820f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; }; A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; }; A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; }; + A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -258,6 +259,7 @@ A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = ""; }; A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = ""; }; A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = ""; }; + A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -626,6 +628,7 @@ A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */, + A5E408462E0485270035FEAC /* InputIntent.swift */, A5E408442E0483F80035FEAC /* KeybindIntent.swift */, A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, ); @@ -819,6 +822,7 @@ A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, + A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */, A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift new file mode 100644 index 000000000..46c849c99 --- /dev/null +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -0,0 +1,92 @@ +import AppKit +import AppIntents + +/// App intent to input text in a terminal. +struct InputTextIntent: AppIntent { + static var title: LocalizedStringResource = "Input Text to Terminal" + + @Parameter( + title: "Text", + description: "The text to input to the terminal. The text will be inputted as if it was pasted.", + inputOptions: String.IntentInputOptions( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var text: String + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + surface.sendText(text) + return .result() + } +} + +/// App intent to trigger a keyboard event. +struct KeyEventIntent: AppIntent { + static var title: LocalizedStringResource = "Send Keyboard Event to Terminal" + static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.") + + @Parameter( + title: "Text", + description: "The key to send to the terminal." + ) + var key: KeyIntentKey + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + surface.sendText(text) + return .result() + } +} + +// MARK: TerminalDetail + +enum KeyIntentKey: String { + case title + case workingDirectory + case allContents + case selectedText + case visibleText +} + +extension KeyIntentKey: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .title: .init(title: "Title"), + .workingDirectory: .init(title: "Working Directory"), + .allContents: .init(title: "Full Contents"), + .selectedText: .init(title: "Selected Text"), + .visibleText: .init(title: "Visible Text"), + ] +} diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift index ddb9c489c..adeb64331 100644 --- a/macos/Sources/Features/App Intents/KeybindIntent.swift +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -1,13 +1,12 @@ import AppKit import AppIntents -/// App intent that invokes a command palette entry. struct KeybindIntent: AppIntent { static var title: LocalizedStringResource = "Invoke a Keybind Action" @Parameter( title: "Terminal", - description: "The terminal to base available commands from." + description: "The terminal to invoke the action on." ) var terminal: TerminalEntity diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 942ca5973..e18203f65 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -208,4 +208,15 @@ extension Ghostty { 0x43: GHOSTTY_KEY_NUMPAD_MULTIPLY, 0x4E: GHOSTTY_KEY_NUMPAD_SUBTRACT, ]; + + /// `ghostty_input_key_e` + enum Key: String { + case undentified + + var cKey: ghostty_input_key_e { + switch self { + case .undentified: GHOSTTY_KEY_UNIDENTIFIED + } + } + } } diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 5560ff3a8..10e699c1f 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -35,6 +35,19 @@ extension Ghostty { } } + /// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard + /// shortcuts and other encodings do not take effect. + @MainActor + func sendText(_ text: String) { + let len = text.utf8CString.count + if (len == 0) { return } + + text.withCString { ptr in + // len includes the null terminator so we do len - 1 + ghostty_surface_text(surface, ptr, UInt(len - 1)) + } + } + /// Perform a keybinding action. /// /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index fe0851261..2e7cf499b 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1700,7 +1700,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { func insertText(_ string: Any, replacementRange: NSRange) { // We must have an associated event guard NSApp.currentEvent != nil else { return } - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // We want the string view of the any value var chars = "" @@ -1724,13 +1724,7 @@ extension Ghostty.SurfaceView: NSTextInputClient { return } - let len = chars.utf8CString.count - if (len == 0) { return } - - chars.withCString { ptr in - // len includes the null terminator so we do len - 1 - ghostty_surface_text(surface, ptr, UInt(len - 1)) - } + surfaceModel.sendText(chars) } /// This function needs to exist for two reasons: From 93619ad42045c42eaf3a1cc1cdb2cd3a32cc2d29 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 11:29:34 -0700 Subject: [PATCH 12/27] macos: Ghostty.Key --- .../Features/App Intents/InputIntent.swift | 25 +- macos/Sources/Ghostty/Ghostty.Input.swift | 905 +++++++++++++++--- macos/Sources/Ghostty/InspectorView.swift | 4 +- 3 files changed, 789 insertions(+), 145 deletions(-) diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index 46c849c99..1b9f88c9f 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -47,7 +47,7 @@ struct KeyEventIntent: AppIntent { title: "Text", description: "The key to send to the terminal." ) - var key: KeyIntentKey + var key: Ghostty.Key @Parameter( title: "Terminal", @@ -64,29 +64,6 @@ struct KeyEventIntent: AppIntent { throw GhosttyIntentError.surfaceNotFound } - surface.sendText(text) return .result() } } - -// MARK: TerminalDetail - -enum KeyIntentKey: String { - case title - case workingDirectory - case allContents - case selectedText - case visibleText -} - -extension KeyIntentKey: AppEnum { - static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail") - - static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ - .title: .init(title: "Title"), - .workingDirectory: .init(title: "Working Directory"), - .allContents: .init(title: "Full Contents"), - .selectedText: .init(title: "Selected Text"), - .visibleText: .init(title: "Visible Text"), - ] -} diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index e18203f65..b3060a44d 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -1,3 +1,4 @@ +import AppIntents import Cocoa import SwiftUI import GhosttyKit @@ -92,131 +93,797 @@ extension Ghostty { GHOSTTY_KEY_SPACE: .space, ] - // Mapping of event keyCode to ghostty input key values. This is cribbed from - // glfw mostly since we started as a glfw-based app way back in the day! - static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [ - 0x1D: GHOSTTY_KEY_DIGIT_0, - 0x12: GHOSTTY_KEY_DIGIT_1, - 0x13: GHOSTTY_KEY_DIGIT_2, - 0x14: GHOSTTY_KEY_DIGIT_3, - 0x15: GHOSTTY_KEY_DIGIT_4, - 0x17: GHOSTTY_KEY_DIGIT_5, - 0x16: GHOSTTY_KEY_DIGIT_6, - 0x1A: GHOSTTY_KEY_DIGIT_7, - 0x1C: GHOSTTY_KEY_DIGIT_8, - 0x19: GHOSTTY_KEY_DIGIT_9, - 0x00: GHOSTTY_KEY_A, - 0x0B: GHOSTTY_KEY_B, - 0x08: GHOSTTY_KEY_C, - 0x02: GHOSTTY_KEY_D, - 0x0E: GHOSTTY_KEY_E, - 0x03: GHOSTTY_KEY_F, - 0x05: GHOSTTY_KEY_G, - 0x04: GHOSTTY_KEY_H, - 0x22: GHOSTTY_KEY_I, - 0x26: GHOSTTY_KEY_J, - 0x28: GHOSTTY_KEY_K, - 0x25: GHOSTTY_KEY_L, - 0x2E: GHOSTTY_KEY_M, - 0x2D: GHOSTTY_KEY_N, - 0x1F: GHOSTTY_KEY_O, - 0x23: GHOSTTY_KEY_P, - 0x0C: GHOSTTY_KEY_Q, - 0x0F: GHOSTTY_KEY_R, - 0x01: GHOSTTY_KEY_S, - 0x11: GHOSTTY_KEY_T, - 0x20: GHOSTTY_KEY_U, - 0x09: GHOSTTY_KEY_V, - 0x0D: GHOSTTY_KEY_W, - 0x07: GHOSTTY_KEY_X, - 0x10: GHOSTTY_KEY_Y, - 0x06: GHOSTTY_KEY_Z, - - 0x27: GHOSTTY_KEY_QUOTE, - 0x2A: GHOSTTY_KEY_BACKSLASH, - 0x2B: GHOSTTY_KEY_COMMA, - 0x18: GHOSTTY_KEY_EQUAL, - 0x32: GHOSTTY_KEY_BACKQUOTE, - 0x21: GHOSTTY_KEY_BRACKET_LEFT, - 0x1B: GHOSTTY_KEY_MINUS, - 0x2F: GHOSTTY_KEY_PERIOD, - 0x1E: GHOSTTY_KEY_BRACKET_RIGHT, - 0x29: GHOSTTY_KEY_SEMICOLON, - 0x2C: GHOSTTY_KEY_SLASH, - - 0x33: GHOSTTY_KEY_BACKSPACE, - 0x39: GHOSTTY_KEY_CAPS_LOCK, - 0x75: GHOSTTY_KEY_DELETE, - 0x7D: GHOSTTY_KEY_ARROW_DOWN, - 0x77: GHOSTTY_KEY_END, - 0x24: GHOSTTY_KEY_ENTER, - 0x35: GHOSTTY_KEY_ESCAPE, - 0x7A: GHOSTTY_KEY_F1, - 0x78: GHOSTTY_KEY_F2, - 0x63: GHOSTTY_KEY_F3, - 0x76: GHOSTTY_KEY_F4, - 0x60: GHOSTTY_KEY_F5, - 0x61: GHOSTTY_KEY_F6, - 0x62: GHOSTTY_KEY_F7, - 0x64: GHOSTTY_KEY_F8, - 0x65: GHOSTTY_KEY_F9, - 0x6D: GHOSTTY_KEY_F10, - 0x67: GHOSTTY_KEY_F11, - 0x6F: GHOSTTY_KEY_F12, - 0x69: GHOSTTY_KEY_PRINT_SCREEN, - 0x6B: GHOSTTY_KEY_F14, - 0x71: GHOSTTY_KEY_F15, - 0x6A: GHOSTTY_KEY_F16, - 0x40: GHOSTTY_KEY_F17, - 0x4F: GHOSTTY_KEY_F18, - 0x50: GHOSTTY_KEY_F19, - 0x5A: GHOSTTY_KEY_F20, - 0x73: GHOSTTY_KEY_HOME, - 0x72: GHOSTTY_KEY_INSERT, - 0x7B: GHOSTTY_KEY_ARROW_LEFT, - 0x3A: GHOSTTY_KEY_ALT_LEFT, - 0x3B: GHOSTTY_KEY_CONTROL_LEFT, - 0x38: GHOSTTY_KEY_SHIFT_LEFT, - 0x37: GHOSTTY_KEY_META_LEFT, - 0x47: GHOSTTY_KEY_NUM_LOCK, - 0x79: GHOSTTY_KEY_PAGE_DOWN, - 0x74: GHOSTTY_KEY_PAGE_UP, - 0x7C: GHOSTTY_KEY_ARROW_RIGHT, - 0x3D: GHOSTTY_KEY_ALT_RIGHT, - 0x3E: GHOSTTY_KEY_CONTROL_RIGHT, - 0x3C: GHOSTTY_KEY_SHIFT_RIGHT, - 0x36: GHOSTTY_KEY_META_RIGHT, - 0x31: GHOSTTY_KEY_SPACE, - 0x30: GHOSTTY_KEY_TAB, - 0x7E: GHOSTTY_KEY_ARROW_UP, - - 0x52: GHOSTTY_KEY_NUMPAD_0, - 0x53: GHOSTTY_KEY_NUMPAD_1, - 0x54: GHOSTTY_KEY_NUMPAD_2, - 0x55: GHOSTTY_KEY_NUMPAD_3, - 0x56: GHOSTTY_KEY_NUMPAD_4, - 0x57: GHOSTTY_KEY_NUMPAD_5, - 0x58: GHOSTTY_KEY_NUMPAD_6, - 0x59: GHOSTTY_KEY_NUMPAD_7, - 0x5B: GHOSTTY_KEY_NUMPAD_8, - 0x5C: GHOSTTY_KEY_NUMPAD_9, - 0x45: GHOSTTY_KEY_NUMPAD_ADD, - 0x41: GHOSTTY_KEY_NUMPAD_DECIMAL, - 0x4B: GHOSTTY_KEY_NUMPAD_DIVIDE, - 0x4C: GHOSTTY_KEY_NUMPAD_ENTER, - 0x51: GHOSTTY_KEY_NUMPAD_EQUAL, - 0x43: GHOSTTY_KEY_NUMPAD_MULTIPLY, - 0x4E: GHOSTTY_KEY_NUMPAD_SUBTRACT, - ]; - /// `ghostty_input_key_e` enum Key: String { - case undentified + // Writing System Keys + case backquote + case backslash + case bracketLeft + case bracketRight + case comma + case digit0 + case digit1 + case digit2 + case digit3 + case digit4 + case digit5 + case digit6 + case digit7 + case digit8 + case digit9 + case equal + case intlBackslash + case intlRo + case intlYen + case a + case b + case c + case d + case e + case f + case g + case h + case i + case j + case k + case l + case m + case n + case o + case p + case q + case r + case s + case t + case u + case v + case w + case x + case y + case z + case minus + case period + case quote + case semicolon + case slash + + // Functional Keys + case altLeft + case altRight + case backspace + case capsLock + case contextMenu + case controlLeft + case controlRight + case enter + case metaLeft + case metaRight + case shiftLeft + case shiftRight + case space + case tab + case convert + case kanaMode + case nonConvert + + // Control Pad Section + case delete + case end + case help + case home + case insert + case pageDown + case pageUp + + // Arrow Pad Section + case arrowDown + case arrowLeft + case arrowRight + case arrowUp + + // Numpad Section + case numLock + case numpad0 + case numpad1 + case numpad2 + case numpad3 + case numpad4 + case numpad5 + case numpad6 + case numpad7 + case numpad8 + case numpad9 + case numpadAdd + case numpadBackspace + case numpadClear + case numpadClearEntry + case numpadComma + case numpadDecimal + case numpadDivide + case numpadEnter + case numpadEqual + case numpadMemoryAdd + case numpadMemoryClear + case numpadMemoryRecall + case numpadMemoryStore + case numpadMemorySubtract + case numpadMultiply + case numpadParenLeft + case numpadParenRight + case numpadSubtract + case numpadSeparator + case numpadUp + case numpadDown + case numpadRight + case numpadLeft + case numpadBegin + case numpadHome + case numpadEnd + case numpadInsert + case numpadDelete + case numpadPageUp + case numpadPageDown + + // Function Section + case escape + case f1 + case f2 + case f3 + case f4 + case f5 + case f6 + case f7 + case f8 + case f9 + case f10 + case f11 + case f12 + case f13 + case f14 + case f15 + case f16 + case f17 + case f18 + case f19 + case f20 + case f21 + case f22 + case f23 + case f24 + case f25 + case fn + case fnLock + case printScreen + case scrollLock + case pause + + // Media Keys + case browserBack + case browserFavorites + case browserForward + case browserHome + case browserRefresh + case browserSearch + case browserStop + case eject + case launchApp1 + case launchApp2 + case launchMail + case mediaPlayPause + case mediaSelect + case mediaStop + case mediaTrackNext + case mediaTrackPrevious + case power + case sleep + case audioVolumeDown + case audioVolumeMute + case audioVolumeUp + case wakeUp + + // Legacy, Non-standard, and Special Keys + case copy + case cut + case paste + + /// Get a key from a keycode + init?(keyCode: UInt16) { + if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) { + self = key + return + } + + return nil + } var cKey: ghostty_input_key_e { switch self { - case .undentified: GHOSTTY_KEY_UNIDENTIFIED + // Writing System Keys + case .backquote: GHOSTTY_KEY_BACKQUOTE + case .backslash: GHOSTTY_KEY_BACKSLASH + case .bracketLeft: GHOSTTY_KEY_BRACKET_LEFT + case .bracketRight: GHOSTTY_KEY_BRACKET_RIGHT + case .comma: GHOSTTY_KEY_COMMA + case .digit0: GHOSTTY_KEY_DIGIT_0 + case .digit1: GHOSTTY_KEY_DIGIT_1 + case .digit2: GHOSTTY_KEY_DIGIT_2 + case .digit3: GHOSTTY_KEY_DIGIT_3 + case .digit4: GHOSTTY_KEY_DIGIT_4 + case .digit5: GHOSTTY_KEY_DIGIT_5 + case .digit6: GHOSTTY_KEY_DIGIT_6 + case .digit7: GHOSTTY_KEY_DIGIT_7 + case .digit8: GHOSTTY_KEY_DIGIT_8 + case .digit9: GHOSTTY_KEY_DIGIT_9 + case .equal: GHOSTTY_KEY_EQUAL + case .intlBackslash: GHOSTTY_KEY_INTL_BACKSLASH + case .intlRo: GHOSTTY_KEY_INTL_RO + case .intlYen: GHOSTTY_KEY_INTL_YEN + case .a: GHOSTTY_KEY_A + case .b: GHOSTTY_KEY_B + case .c: GHOSTTY_KEY_C + case .d: GHOSTTY_KEY_D + case .e: GHOSTTY_KEY_E + case .f: GHOSTTY_KEY_F + case .g: GHOSTTY_KEY_G + case .h: GHOSTTY_KEY_H + case .i: GHOSTTY_KEY_I + case .j: GHOSTTY_KEY_J + case .k: GHOSTTY_KEY_K + case .l: GHOSTTY_KEY_L + case .m: GHOSTTY_KEY_M + case .n: GHOSTTY_KEY_N + case .o: GHOSTTY_KEY_O + case .p: GHOSTTY_KEY_P + case .q: GHOSTTY_KEY_Q + case .r: GHOSTTY_KEY_R + case .s: GHOSTTY_KEY_S + case .t: GHOSTTY_KEY_T + case .u: GHOSTTY_KEY_U + case .v: GHOSTTY_KEY_V + case .w: GHOSTTY_KEY_W + case .x: GHOSTTY_KEY_X + case .y: GHOSTTY_KEY_Y + case .z: GHOSTTY_KEY_Z + case .minus: GHOSTTY_KEY_MINUS + case .period: GHOSTTY_KEY_PERIOD + case .quote: GHOSTTY_KEY_QUOTE + case .semicolon: GHOSTTY_KEY_SEMICOLON + case .slash: GHOSTTY_KEY_SLASH + + // Functional Keys + case .altLeft: GHOSTTY_KEY_ALT_LEFT + case .altRight: GHOSTTY_KEY_ALT_RIGHT + case .backspace: GHOSTTY_KEY_BACKSPACE + case .capsLock: GHOSTTY_KEY_CAPS_LOCK + case .contextMenu: GHOSTTY_KEY_CONTEXT_MENU + case .controlLeft: GHOSTTY_KEY_CONTROL_LEFT + case .controlRight: GHOSTTY_KEY_CONTROL_RIGHT + case .enter: GHOSTTY_KEY_ENTER + case .metaLeft: GHOSTTY_KEY_META_LEFT + case .metaRight: GHOSTTY_KEY_META_RIGHT + case .shiftLeft: GHOSTTY_KEY_SHIFT_LEFT + case .shiftRight: GHOSTTY_KEY_SHIFT_RIGHT + case .space: GHOSTTY_KEY_SPACE + case .tab: GHOSTTY_KEY_TAB + case .convert: GHOSTTY_KEY_CONVERT + case .kanaMode: GHOSTTY_KEY_KANA_MODE + case .nonConvert: GHOSTTY_KEY_NON_CONVERT + + // Control Pad Section + case .delete: GHOSTTY_KEY_DELETE + case .end: GHOSTTY_KEY_END + case .help: GHOSTTY_KEY_HELP + case .home: GHOSTTY_KEY_HOME + case .insert: GHOSTTY_KEY_INSERT + case .pageDown: GHOSTTY_KEY_PAGE_DOWN + case .pageUp: GHOSTTY_KEY_PAGE_UP + + // Arrow Pad Section + case .arrowDown: GHOSTTY_KEY_ARROW_DOWN + case .arrowLeft: GHOSTTY_KEY_ARROW_LEFT + case .arrowRight: GHOSTTY_KEY_ARROW_RIGHT + case .arrowUp: GHOSTTY_KEY_ARROW_UP + + // Numpad Section + case .numLock: GHOSTTY_KEY_NUM_LOCK + case .numpad0: GHOSTTY_KEY_NUMPAD_0 + case .numpad1: GHOSTTY_KEY_NUMPAD_1 + case .numpad2: GHOSTTY_KEY_NUMPAD_2 + case .numpad3: GHOSTTY_KEY_NUMPAD_3 + case .numpad4: GHOSTTY_KEY_NUMPAD_4 + case .numpad5: GHOSTTY_KEY_NUMPAD_5 + case .numpad6: GHOSTTY_KEY_NUMPAD_6 + case .numpad7: GHOSTTY_KEY_NUMPAD_7 + case .numpad8: GHOSTTY_KEY_NUMPAD_8 + case .numpad9: GHOSTTY_KEY_NUMPAD_9 + case .numpadAdd: GHOSTTY_KEY_NUMPAD_ADD + case .numpadBackspace: GHOSTTY_KEY_NUMPAD_BACKSPACE + case .numpadClear: GHOSTTY_KEY_NUMPAD_CLEAR + case .numpadClearEntry: GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY + case .numpadComma: GHOSTTY_KEY_NUMPAD_COMMA + case .numpadDecimal: GHOSTTY_KEY_NUMPAD_DECIMAL + case .numpadDivide: GHOSTTY_KEY_NUMPAD_DIVIDE + case .numpadEnter: GHOSTTY_KEY_NUMPAD_ENTER + case .numpadEqual: GHOSTTY_KEY_NUMPAD_EQUAL + case .numpadMemoryAdd: GHOSTTY_KEY_NUMPAD_MEMORY_ADD + case .numpadMemoryClear: GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR + case .numpadMemoryRecall: GHOSTTY_KEY_NUMPAD_MEMORY_RECALL + case .numpadMemoryStore: GHOSTTY_KEY_NUMPAD_MEMORY_STORE + case .numpadMemorySubtract: GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT + case .numpadMultiply: GHOSTTY_KEY_NUMPAD_MULTIPLY + case .numpadParenLeft: GHOSTTY_KEY_NUMPAD_PAREN_LEFT + case .numpadParenRight: GHOSTTY_KEY_NUMPAD_PAREN_RIGHT + case .numpadSubtract: GHOSTTY_KEY_NUMPAD_SUBTRACT + case .numpadSeparator: GHOSTTY_KEY_NUMPAD_SEPARATOR + case .numpadUp: GHOSTTY_KEY_NUMPAD_UP + case .numpadDown: GHOSTTY_KEY_NUMPAD_DOWN + case .numpadRight: GHOSTTY_KEY_NUMPAD_RIGHT + case .numpadLeft: GHOSTTY_KEY_NUMPAD_LEFT + case .numpadBegin: GHOSTTY_KEY_NUMPAD_BEGIN + case .numpadHome: GHOSTTY_KEY_NUMPAD_HOME + case .numpadEnd: GHOSTTY_KEY_NUMPAD_END + case .numpadInsert: GHOSTTY_KEY_NUMPAD_INSERT + case .numpadDelete: GHOSTTY_KEY_NUMPAD_DELETE + case .numpadPageUp: GHOSTTY_KEY_NUMPAD_PAGE_UP + case .numpadPageDown: GHOSTTY_KEY_NUMPAD_PAGE_DOWN + + // Function Section + case .escape: GHOSTTY_KEY_ESCAPE + case .f1: GHOSTTY_KEY_F1 + case .f2: GHOSTTY_KEY_F2 + case .f3: GHOSTTY_KEY_F3 + case .f4: GHOSTTY_KEY_F4 + case .f5: GHOSTTY_KEY_F5 + case .f6: GHOSTTY_KEY_F6 + case .f7: GHOSTTY_KEY_F7 + case .f8: GHOSTTY_KEY_F8 + case .f9: GHOSTTY_KEY_F9 + case .f10: GHOSTTY_KEY_F10 + case .f11: GHOSTTY_KEY_F11 + case .f12: GHOSTTY_KEY_F12 + case .f13: GHOSTTY_KEY_F13 + case .f14: GHOSTTY_KEY_F14 + case .f15: GHOSTTY_KEY_F15 + case .f16: GHOSTTY_KEY_F16 + case .f17: GHOSTTY_KEY_F17 + case .f18: GHOSTTY_KEY_F18 + case .f19: GHOSTTY_KEY_F19 + case .f20: GHOSTTY_KEY_F20 + case .f21: GHOSTTY_KEY_F21 + case .f22: GHOSTTY_KEY_F22 + case .f23: GHOSTTY_KEY_F23 + case .f24: GHOSTTY_KEY_F24 + case .f25: GHOSTTY_KEY_F25 + case .fn: GHOSTTY_KEY_FN + case .fnLock: GHOSTTY_KEY_FN_LOCK + case .printScreen: GHOSTTY_KEY_PRINT_SCREEN + case .scrollLock: GHOSTTY_KEY_SCROLL_LOCK + case .pause: GHOSTTY_KEY_PAUSE + + // Media Keys + case .browserBack: GHOSTTY_KEY_BROWSER_BACK + case .browserFavorites: GHOSTTY_KEY_BROWSER_FAVORITES + case .browserForward: GHOSTTY_KEY_BROWSER_FORWARD + case .browserHome: GHOSTTY_KEY_BROWSER_HOME + case .browserRefresh: GHOSTTY_KEY_BROWSER_REFRESH + case .browserSearch: GHOSTTY_KEY_BROWSER_SEARCH + case .browserStop: GHOSTTY_KEY_BROWSER_STOP + case .eject: GHOSTTY_KEY_EJECT + case .launchApp1: GHOSTTY_KEY_LAUNCH_APP_1 + case .launchApp2: GHOSTTY_KEY_LAUNCH_APP_2 + case .launchMail: GHOSTTY_KEY_LAUNCH_MAIL + case .mediaPlayPause: GHOSTTY_KEY_MEDIA_PLAY_PAUSE + case .mediaSelect: GHOSTTY_KEY_MEDIA_SELECT + case .mediaStop: GHOSTTY_KEY_MEDIA_STOP + case .mediaTrackNext: GHOSTTY_KEY_MEDIA_TRACK_NEXT + case .mediaTrackPrevious: GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS + case .power: GHOSTTY_KEY_POWER + case .sleep: GHOSTTY_KEY_SLEEP + case .audioVolumeDown: GHOSTTY_KEY_AUDIO_VOLUME_DOWN + case .audioVolumeMute: GHOSTTY_KEY_AUDIO_VOLUME_MUTE + case .audioVolumeUp: GHOSTTY_KEY_AUDIO_VOLUME_UP + case .wakeUp: GHOSTTY_KEY_WAKE_UP + + // Legacy, Non-standard, and Special Keys + case .copy: GHOSTTY_KEY_COPY + case .cut: GHOSTTY_KEY_CUT + case .paste: GHOSTTY_KEY_PASTE + } + } + + // Based on src/input/keycodes.zig + var keyCode: UInt16? { + switch self { + // Writing System Keys + case .backquote: return 0x0032 + case .backslash: return 0x002a + case .bracketLeft: return 0x0021 + case .bracketRight: return 0x001e + case .comma: return 0x002b + case .digit0: return 0x001d + case .digit1: return 0x0012 + case .digit2: return 0x0013 + case .digit3: return 0x0014 + case .digit4: return 0x0015 + case .digit5: return 0x0017 + case .digit6: return 0x0016 + case .digit7: return 0x001a + case .digit8: return 0x001c + case .digit9: return 0x0019 + case .equal: return 0x0018 + case .intlBackslash: return 0x000a + case .intlRo: return 0x005e + case .intlYen: return 0x005d + case .a: return 0x0000 + case .b: return 0x000b + case .c: return 0x0008 + case .d: return 0x0002 + case .e: return 0x000e + case .f: return 0x0003 + case .g: return 0x0005 + case .h: return 0x0004 + case .i: return 0x0022 + case .j: return 0x0026 + case .k: return 0x0028 + case .l: return 0x0025 + case .m: return 0x002e + case .n: return 0x002d + case .o: return 0x001f + case .p: return 0x0023 + case .q: return 0x000c + case .r: return 0x000f + case .s: return 0x0001 + case .t: return 0x0011 + case .u: return 0x0020 + case .v: return 0x0009 + case .w: return 0x000d + case .x: return 0x0007 + case .y: return 0x0010 + case .z: return 0x0006 + case .minus: return 0x001b + case .period: return 0x002f + case .quote: return 0x0027 + case .semicolon: return 0x0029 + case .slash: return 0x002c + + // Functional Keys + case .altLeft: return 0x003a + case .altRight: return 0x003d + case .backspace: return 0x0033 + case .capsLock: return 0x0039 + case .contextMenu: return 0x006e + case .controlLeft: return 0x003b + case .controlRight: return 0x003e + case .enter: return 0x0024 + case .metaLeft: return 0x0037 + case .metaRight: return 0x0036 + case .shiftLeft: return 0x0038 + case .shiftRight: return 0x003c + case .space: return 0x0031 + case .tab: return 0x0030 + case .convert: return nil // No Mac keycode + case .kanaMode: return nil // No Mac keycode + case .nonConvert: return nil // No Mac keycode + + // Control Pad Section + case .delete: return 0x0075 + case .end: return 0x0077 + case .help: return nil // No Mac keycode + case .home: return 0x0073 + case .insert: return 0x0072 + case .pageDown: return 0x0079 + case .pageUp: return 0x0074 + + // Arrow Pad Section + case .arrowDown: return 0x007d + case .arrowLeft: return 0x007b + case .arrowRight: return 0x007c + case .arrowUp: return 0x007e + + // Numpad Section + case .numLock: return 0x0047 + case .numpad0: return 0x0052 + case .numpad1: return 0x0053 + case .numpad2: return 0x0054 + case .numpad3: return 0x0055 + case .numpad4: return 0x0056 + case .numpad5: return 0x0057 + case .numpad6: return 0x0058 + case .numpad7: return 0x0059 + case .numpad8: return 0x005b + case .numpad9: return 0x005c + case .numpadAdd: return 0x0045 + case .numpadBackspace: return nil // No Mac keycode + case .numpadClear: return nil // No Mac keycode + case .numpadClearEntry: return nil // No Mac keycode + case .numpadComma: return 0x005f + case .numpadDecimal: return 0x0041 + case .numpadDivide: return 0x004b + case .numpadEnter: return 0x004c + case .numpadEqual: return 0x0051 + case .numpadMemoryAdd: return nil // No Mac keycode + case .numpadMemoryClear: return nil // No Mac keycode + case .numpadMemoryRecall: return nil // No Mac keycode + case .numpadMemoryStore: return nil // No Mac keycode + case .numpadMemorySubtract: return nil // No Mac keycode + case .numpadMultiply: return 0x0043 + case .numpadParenLeft: return nil // No Mac keycode + case .numpadParenRight: return nil // No Mac keycode + case .numpadSubtract: return 0x004e + case .numpadSeparator: return nil // No Mac keycode + case .numpadUp: return nil // No Mac keycode + case .numpadDown: return nil // No Mac keycode + case .numpadRight: return nil // No Mac keycode + case .numpadLeft: return nil // No Mac keycode + case .numpadBegin: return nil // No Mac keycode + case .numpadHome: return nil // No Mac keycode + case .numpadEnd: return nil // No Mac keycode + case .numpadInsert: return nil // No Mac keycode + case .numpadDelete: return nil // No Mac keycode + case .numpadPageUp: return nil // No Mac keycode + case .numpadPageDown: return nil // No Mac keycode + + // Function Section + case .escape: return 0x0035 + case .f1: return 0x007a + case .f2: return 0x0078 + case .f3: return 0x0063 + case .f4: return 0x0076 + case .f5: return 0x0060 + case .f6: return 0x0061 + case .f7: return 0x0062 + case .f8: return 0x0064 + case .f9: return 0x0065 + case .f10: return 0x006d + case .f11: return 0x0067 + case .f12: return 0x006f + case .f13: return 0x0069 + case .f14: return 0x006b + case .f15: return 0x0071 + case .f16: return 0x006a + case .f17: return 0x0040 + case .f18: return 0x004f + case .f19: return 0x0050 + case .f20: return 0x005a + case .f21: return nil // No Mac keycode + case .f22: return nil // No Mac keycode + case .f23: return nil // No Mac keycode + case .f24: return nil // No Mac keycode + case .f25: return nil // No Mac keycode + case .fn: return nil // No Mac keycode + case .fnLock: return nil // No Mac keycode + case .printScreen: return nil // No Mac keycode + case .scrollLock: return nil // No Mac keycode + case .pause: return nil // No Mac keycode + + // Media Keys + case .browserBack: return nil // No Mac keycode + case .browserFavorites: return nil // No Mac keycode + case .browserForward: return nil // No Mac keycode + case .browserHome: return nil // No Mac keycode + case .browserRefresh: return nil // No Mac keycode + case .browserSearch: return nil // No Mac keycode + case .browserStop: return nil // No Mac keycode + case .eject: return nil // No Mac keycode + case .launchApp1: return nil // No Mac keycode + case .launchApp2: return nil // No Mac keycode + case .launchMail: return nil // No Mac keycode + case .mediaPlayPause: return nil // No Mac keycode + case .mediaSelect: return nil // No Mac keycode + case .mediaStop: return nil // No Mac keycode + case .mediaTrackNext: return nil // No Mac keycode + case .mediaTrackPrevious: return nil // No Mac keycode + case .power: return nil // No Mac keycode + case .sleep: return nil // No Mac keycode + case .audioVolumeDown: return 0x0049 + case .audioVolumeMute: return 0x004a + case .audioVolumeUp: return 0x0048 + case .wakeUp: return nil // No Mac keycode + + // Legacy, Non-standard, and Special Keys + case .copy: return nil // No Mac keycode + case .cut: return nil // No Mac keycode + case .paste: return nil // No Mac keycode } } } } + +// MARK: Ghostty.Key AppEnum + +extension Ghostty.Key: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Key" + + static var caseDisplayRepresentations: [Ghostty.Key : DisplayRepresentation] = [ + // Writing System Keys + .backquote: "Backtick (`)", + .backslash: "Backslash (\\)", + .bracketLeft: "Left Bracket ([)", + .bracketRight: "Right Bracket (])", + .comma: "Comma (,)", + .digit0: "0", + .digit1: "1", + .digit2: "2", + .digit3: "3", + .digit4: "4", + .digit5: "5", + .digit6: "6", + .digit7: "7", + .digit8: "8", + .digit9: "9", + .equal: "Equal (=)", + .intlBackslash: "International Backslash", + .intlRo: "International Ro", + .intlYen: "International Yen", + .a: "A", + .b: "B", + .c: "C", + .d: "D", + .e: "E", + .f: "F", + .g: "G", + .h: "H", + .i: "I", + .j: "J", + .k: "K", + .l: "L", + .m: "M", + .n: "N", + .o: "O", + .p: "P", + .q: "Q", + .r: "R", + .s: "S", + .t: "T", + .u: "U", + .v: "V", + .w: "W", + .x: "X", + .y: "Y", + .z: "Z", + .minus: "Minus (-)", + .period: "Period (.)", + .quote: "Quote (')", + .semicolon: "Semicolon (;)", + .slash: "Slash (/)", + + // Functional Keys + .altLeft: "Left Alt", + .altRight: "Right Alt", + .backspace: "Backspace", + .capsLock: "Caps Lock", + .contextMenu: "Context Menu", + .controlLeft: "Left Control", + .controlRight: "Right Control", + .enter: "Enter", + .metaLeft: "Left Command", + .metaRight: "Right Command", + .shiftLeft: "Left Shift", + .shiftRight: "Right Shift", + .space: "Space", + .tab: "Tab", + .convert: "Convert", + .kanaMode: "Kana Mode", + .nonConvert: "Non Convert", + + // Control Pad Section + .delete: "Delete", + .end: "End", + .help: "Help", + .home: "Home", + .insert: "Insert", + .pageDown: "Page Down", + .pageUp: "Page Up", + + // Arrow Pad Section + .arrowDown: "Down Arrow", + .arrowLeft: "Left Arrow", + .arrowRight: "Right Arrow", + .arrowUp: "Up Arrow", + + // Numpad Section + .numLock: "Num Lock", + .numpad0: "Numpad 0", + .numpad1: "Numpad 1", + .numpad2: "Numpad 2", + .numpad3: "Numpad 3", + .numpad4: "Numpad 4", + .numpad5: "Numpad 5", + .numpad6: "Numpad 6", + .numpad7: "Numpad 7", + .numpad8: "Numpad 8", + .numpad9: "Numpad 9", + .numpadAdd: "Numpad Add (+)", + .numpadBackspace: "Numpad Backspace", + .numpadClear: "Numpad Clear", + .numpadClearEntry: "Numpad Clear Entry", + .numpadComma: "Numpad Comma", + .numpadDecimal: "Numpad Decimal", + .numpadDivide: "Numpad Divide (÷)", + .numpadEnter: "Numpad Enter", + .numpadEqual: "Numpad Equal", + .numpadMemoryAdd: "Numpad Memory Add", + .numpadMemoryClear: "Numpad Memory Clear", + .numpadMemoryRecall: "Numpad Memory Recall", + .numpadMemoryStore: "Numpad Memory Store", + .numpadMemorySubtract: "Numpad Memory Subtract", + .numpadMultiply: "Numpad Multiply (×)", + .numpadParenLeft: "Numpad Left Parenthesis", + .numpadParenRight: "Numpad Right Parenthesis", + .numpadSubtract: "Numpad Subtract (-)", + .numpadSeparator: "Numpad Separator", + .numpadUp: "Numpad Up", + .numpadDown: "Numpad Down", + .numpadRight: "Numpad Right", + .numpadLeft: "Numpad Left", + .numpadBegin: "Numpad Begin", + .numpadHome: "Numpad Home", + .numpadEnd: "Numpad End", + .numpadInsert: "Numpad Insert", + .numpadDelete: "Numpad Delete", + .numpadPageUp: "Numpad Page Up", + .numpadPageDown: "Numpad Page Down", + + // Function Section + .escape: "Escape", + .f1: "F1", + .f2: "F2", + .f3: "F3", + .f4: "F4", + .f5: "F5", + .f6: "F6", + .f7: "F7", + .f8: "F8", + .f9: "F9", + .f10: "F10", + .f11: "F11", + .f12: "F12", + .f13: "F13", + .f14: "F14", + .f15: "F15", + .f16: "F16", + .f17: "F17", + .f18: "F18", + .f19: "F19", + .f20: "F20", + .f21: "F21", + .f22: "F22", + .f23: "F23", + .f24: "F24", + .f25: "F25", + .fn: "Fn", + .fnLock: "Fn Lock", + .printScreen: "Print Screen", + .scrollLock: "Scroll Lock", + .pause: "Pause", + + // Media Keys + .browserBack: "Browser Back", + .browserFavorites: "Browser Favorites", + .browserForward: "Browser Forward", + .browserHome: "Browser Home", + .browserRefresh: "Browser Refresh", + .browserSearch: "Browser Search", + .browserStop: "Browser Stop", + .eject: "Eject", + .launchApp1: "Launch App 1", + .launchApp2: "Launch App 2", + .launchMail: "Launch Mail", + .mediaPlayPause: "Media Play/Pause", + .mediaSelect: "Media Select", + .mediaStop: "Media Stop", + .mediaTrackNext: "Media Next Track", + .mediaTrackPrevious: "Media Previous Track", + .power: "Power", + .sleep: "Sleep", + .audioVolumeDown: "Volume Down", + .audioVolumeMute: "Volume Mute", + .audioVolumeUp: "Volume Up", + .wakeUp: "Wake Up", + + // Legacy, Non-standard, and Special Keys + .copy: "Copy", + .cut: "Cut", + .paste: "Paste" + ] +} diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index a6e80bd47..491ec86e1 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -337,9 +337,9 @@ extension Ghostty { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let inspector = self.inspector else { return } - guard let key = Ghostty.keycodeToKey[event.keyCode] else { return } + guard let key = Ghostty.Key(keyCode: event.keyCode) else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_key(inspector, action, key, mods) + ghostty_inspector_key(inspector, action, key.cKey, mods) } // MARK: NSTextInputClient From 71b6e223af1ef2e2033326792e86bc9587326e91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 12:06:27 -0700 Subject: [PATCH 13/27] macos: input keyboard event can send modifiers and actions now --- .../Features/App Intents/InputIntent.swift | 60 ++- macos/Sources/Ghostty/Ghostty.Input.swift | 509 +++++++++++------- macos/Sources/Ghostty/Ghostty.Surface.swift | 14 + macos/Sources/Ghostty/InspectorView.swift | 2 +- 4 files changed, 382 insertions(+), 203 deletions(-) diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index 1b9f88c9f..6d3d60d59 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -44,10 +44,25 @@ struct KeyEventIntent: AppIntent { static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.") @Parameter( - title: "Text", - description: "The key to send to the terminal." + title: "Key", + description: "The key to send to the terminal.", + default: .enter ) - var key: Ghostty.Key + var key: Ghostty.Input.Key + + @Parameter( + title: "Modifier(s)", + description: "The modifiers to send with the key event.", + default: [] + ) + var mods: [KeyEventMods] + + @Parameter( + title: "Event Type", + description: "A key press or release.", + default: .press + ) + var action: Ghostty.Input.Action @Parameter( title: "Terminal", @@ -64,6 +79,45 @@ struct KeyEventIntent: AppIntent { throw GhosttyIntentError.surfaceNotFound } + // Convert KeyEventMods array to Ghostty.Input.Mods + let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in + result.union(mod.ghosttyMod) + } + + let keyEvent = Ghostty.Input.KeyEvent( + key: key, + action: action, + mods: ghosttyMods + ) + surface.sendKeyEvent(keyEvent) + return .result() } } + +// MARK: Mods + +enum KeyEventMods: String, AppEnum, CaseIterable { + case shift + case control + case option + case command + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key") + + static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [ + .shift: "Shift", + .control: "Control", + .option: "Option", + .command: "Command" + ] + + var ghosttyMod: Ghostty.Input.Mods { + switch self { + case .shift: .shift + case .control: .ctrl + case .option: .alt + case .command: .super + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index b3060a44d..df93017c7 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -4,6 +4,8 @@ import SwiftUI import GhosttyKit extension Ghostty { + struct Input {} + // MARK: Keyboard Shortcuts /// Return the key equivalent for the given trigger. @@ -92,7 +94,175 @@ extension Ghostty { GHOSTTY_KEY_BACKSPACE: .delete, GHOSTTY_KEY_SPACE: .space, ] +} +// MARK: Ghostty.Input.KeyEvent + +extension Ghostty.Input { + /// `ghostty_input_key_s` + struct KeyEvent { + let action: Action + let key: Key + let text: String? + let composing: Bool + let mods: Mods + let consumedMods: Mods + let unshiftedCodepoint: UInt32 + + init( + key: Key, + action: Action = .press, + text: String? = nil, + composing: Bool = false, + mods: Mods = [], + consumedMods: Mods = [], + unshiftedCodepoint: UInt32 = 0 + ) { + self.key = key + self.action = action + self.text = text + self.composing = composing + self.mods = mods + self.consumedMods = consumedMods + self.unshiftedCodepoint = unshiftedCodepoint + } + + init?(cValue: ghostty_input_key_s) { + // Convert action + switch cValue.action { + case GHOSTTY_ACTION_PRESS: self.action = .press + case GHOSTTY_ACTION_RELEASE: self.action = .release + case GHOSTTY_ACTION_REPEAT: self.action = .repeat + default: self.action = .press + } + + // Convert key from keycode + guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil } + self.key = key + + // Convert text + if let textPtr = cValue.text { + self.text = String(cString: textPtr) + } else { + self.text = nil + } + + // Set composing state + self.composing = cValue.composing + + // Convert modifiers + self.mods = Mods(cMods: cValue.mods) + self.consumedMods = Mods(cMods: cValue.consumed_mods) + + // Set unshifted codepoint + self.unshiftedCodepoint = cValue.unshifted_codepoint + } + + /// Executes a closure with a temporary C representation of this KeyEvent. + /// + /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct + /// and passes it to the provided closure. The C struct is only valid within the closure's + /// execution scope. The text field's C string pointer is managed automatically and will + /// be invalid after the closure returns. + /// + /// - Parameter execute: A closure that receives the C struct and returns a value + /// - Returns: The value returned by the closure + @discardableResult + func withCValue(execute: (ghostty_input_key_s) -> T) -> T { + var keyEvent = ghostty_input_key_s() + keyEvent.action = action.cAction + keyEvent.keycode = UInt32(key.keyCode ?? 0) + keyEvent.composing = composing + keyEvent.mods = mods.cMods + keyEvent.consumed_mods = consumedMods.cMods + keyEvent.unshifted_codepoint = unshiftedCodepoint + + // Handle text with proper memory management + if let text = text { + return text.withCString { textPtr in + keyEvent.text = textPtr + return execute(keyEvent) + } + } else { + keyEvent.text = nil + return execute(keyEvent) + } + } + } +} + +// MARK: Ghostty.Input.Action + +extension Ghostty.Input { + /// `ghostty_input_action_e` + enum Action: String, CaseIterable { + case release + case press + case `repeat` + + var cAction: ghostty_input_action_e { + switch self { + case .release: GHOSTTY_ACTION_RELEASE + case .press: GHOSTTY_ACTION_PRESS + case .repeat: GHOSTTY_ACTION_REPEAT + } + } + } +} + +extension Ghostty.Input.Action: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key Action") + + static var caseDisplayRepresentations: [Ghostty.Input.Action : DisplayRepresentation] = [ + .release: "Release", + .press: "Press", + .repeat: "Repeat" + ] +} + +// MARK: Ghostty.Input.Mods + +extension Ghostty.Input { + /// `ghostty_input_mods_e` + struct Mods: OptionSet { + let rawValue: UInt32 + + static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue) + static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue) + static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue) + static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue) + static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue) + static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue) + static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue) + static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue) + static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue) + static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue) + + var cMods: ghostty_input_mods_e { + ghostty_input_mods_e(rawValue) + } + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + init(cMods: ghostty_input_mods_e) { + self.rawValue = cMods.rawValue + } + + init(nsFlags: NSEvent.ModifierFlags) { + self.init(cMods: Ghostty.ghosttyMods(nsFlags)) + } + + var nsFlags: NSEvent.ModifierFlags { + Ghostty.eventModifierFlags(mods: cMods) + } + } +} + +// MARK: Ghostty.Input.Key + +extension Ghostty.Input { /// `ghostty_input_key_e` enum Key: String { // Writing System Keys @@ -146,7 +316,7 @@ extension Ghostty { case quote case semicolon case slash - + // Functional Keys case altLeft case altRight @@ -165,7 +335,7 @@ extension Ghostty { case convert case kanaMode case nonConvert - + // Control Pad Section case delete case end @@ -174,13 +344,13 @@ extension Ghostty { case insert case pageDown case pageUp - + // Arrow Pad Section case arrowDown case arrowLeft case arrowRight case arrowUp - + // Numpad Section case numLock case numpad0 @@ -223,7 +393,7 @@ extension Ghostty { case numpadDelete case numpadPageUp case numpadPageDown - + // Function Section case escape case f1 @@ -256,7 +426,7 @@ extension Ghostty { case printScreen case scrollLock case pause - + // Media Keys case browserBack case browserFavorites @@ -280,7 +450,7 @@ extension Ghostty { case audioVolumeMute case audioVolumeUp case wakeUp - + // Legacy, Non-standard, and Special Keys case copy case cut @@ -349,7 +519,7 @@ extension Ghostty { case .quote: GHOSTTY_KEY_QUOTE case .semicolon: GHOSTTY_KEY_SEMICOLON case .slash: GHOSTTY_KEY_SLASH - + // Functional Keys case .altLeft: GHOSTTY_KEY_ALT_LEFT case .altRight: GHOSTTY_KEY_ALT_RIGHT @@ -368,7 +538,7 @@ extension Ghostty { case .convert: GHOSTTY_KEY_CONVERT case .kanaMode: GHOSTTY_KEY_KANA_MODE case .nonConvert: GHOSTTY_KEY_NON_CONVERT - + // Control Pad Section case .delete: GHOSTTY_KEY_DELETE case .end: GHOSTTY_KEY_END @@ -377,13 +547,13 @@ extension Ghostty { case .insert: GHOSTTY_KEY_INSERT case .pageDown: GHOSTTY_KEY_PAGE_DOWN case .pageUp: GHOSTTY_KEY_PAGE_UP - + // Arrow Pad Section case .arrowDown: GHOSTTY_KEY_ARROW_DOWN case .arrowLeft: GHOSTTY_KEY_ARROW_LEFT case .arrowRight: GHOSTTY_KEY_ARROW_RIGHT case .arrowUp: GHOSTTY_KEY_ARROW_UP - + // Numpad Section case .numLock: GHOSTTY_KEY_NUM_LOCK case .numpad0: GHOSTTY_KEY_NUMPAD_0 @@ -426,7 +596,7 @@ extension Ghostty { case .numpadDelete: GHOSTTY_KEY_NUMPAD_DELETE case .numpadPageUp: GHOSTTY_KEY_NUMPAD_PAGE_UP case .numpadPageDown: GHOSTTY_KEY_NUMPAD_PAGE_DOWN - + // Function Section case .escape: GHOSTTY_KEY_ESCAPE case .f1: GHOSTTY_KEY_F1 @@ -459,7 +629,7 @@ extension Ghostty { case .printScreen: GHOSTTY_KEY_PRINT_SCREEN case .scrollLock: GHOSTTY_KEY_SCROLL_LOCK case .pause: GHOSTTY_KEY_PAUSE - + // Media Keys case .browserBack: GHOSTTY_KEY_BROWSER_BACK case .browserFavorites: GHOSTTY_KEY_BROWSER_FAVORITES @@ -483,7 +653,7 @@ extension Ghostty { case .audioVolumeMute: GHOSTTY_KEY_AUDIO_VOLUME_MUTE case .audioVolumeUp: GHOSTTY_KEY_AUDIO_VOLUME_UP case .wakeUp: GHOSTTY_KEY_WAKE_UP - + // Legacy, Non-standard, and Special Keys case .copy: GHOSTTY_KEY_COPY case .cut: GHOSTTY_KEY_CUT @@ -545,7 +715,7 @@ extension Ghostty { case .quote: return 0x0027 case .semicolon: return 0x0029 case .slash: return 0x002c - + // Functional Keys case .altLeft: return 0x003a case .altRight: return 0x003d @@ -564,7 +734,7 @@ extension Ghostty { case .convert: return nil // No Mac keycode case .kanaMode: return nil // No Mac keycode case .nonConvert: return nil // No Mac keycode - + // Control Pad Section case .delete: return 0x0075 case .end: return 0x0077 @@ -573,13 +743,13 @@ extension Ghostty { case .insert: return 0x0072 case .pageDown: return 0x0079 case .pageUp: return 0x0074 - + // Arrow Pad Section case .arrowDown: return 0x007d case .arrowLeft: return 0x007b case .arrowRight: return 0x007c case .arrowUp: return 0x007e - + // Numpad Section case .numLock: return 0x0047 case .numpad0: return 0x0052 @@ -622,7 +792,7 @@ extension Ghostty { case .numpadDelete: return nil // No Mac keycode case .numpadPageUp: return nil // No Mac keycode case .numpadPageDown: return nil // No Mac keycode - + // Function Section case .escape: return 0x0035 case .f1: return 0x007a @@ -655,7 +825,7 @@ extension Ghostty { case .printScreen: return nil // No Mac keycode case .scrollLock: return nil // No Mac keycode case .pause: return nil // No Mac keycode - + // Media Keys case .browserBack: return nil // No Mac keycode case .browserFavorites: return nil // No Mac keycode @@ -679,7 +849,7 @@ extension Ghostty { case .audioVolumeMute: return 0x004a case .audioVolumeUp: return 0x0048 case .wakeUp: return nil // No Mac keycode - + // Legacy, Non-standard, and Special Keys case .copy: return nil // No Mac keycode case .cut: return nil // No Mac keycode @@ -689,201 +859,142 @@ extension Ghostty { } } -// MARK: Ghostty.Key AppEnum +extension Ghostty.Input.Key: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key") -extension Ghostty.Key: AppEnum { - static var typeDisplayRepresentation: TypeDisplayRepresentation = "Key" - - static var caseDisplayRepresentations: [Ghostty.Key : DisplayRepresentation] = [ - // Writing System Keys - .backquote: "Backtick (`)", - .backslash: "Backslash (\\)", - .bracketLeft: "Left Bracket ([)", - .bracketRight: "Right Bracket (])", - .comma: "Comma (,)", - .digit0: "0", - .digit1: "1", - .digit2: "2", - .digit3: "3", - .digit4: "4", - .digit5: "5", - .digit6: "6", - .digit7: "7", - .digit8: "8", - .digit9: "9", - .equal: "Equal (=)", - .intlBackslash: "International Backslash", - .intlRo: "International Ro", - .intlYen: "International Yen", - .a: "A", - .b: "B", - .c: "C", - .d: "D", - .e: "E", - .f: "F", - .g: "G", - .h: "H", - .i: "I", - .j: "J", - .k: "K", - .l: "L", - .m: "M", - .n: "N", - .o: "O", - .p: "P", - .q: "Q", - .r: "R", - .s: "S", - .t: "T", - .u: "U", - .v: "V", - .w: "W", - .x: "X", - .y: "Y", - .z: "Z", - .minus: "Minus (-)", - .period: "Period (.)", - .quote: "Quote (')", - .semicolon: "Semicolon (;)", - .slash: "Slash (/)", + // Only include keys that have Mac keycodes for App Intents + static var allCases: [Ghostty.Input.Key] { + return [ + // Letters (A-Z) + .a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z, + + // Numbers (0-9) + .digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9, + + // Common Control Keys + .space, .enter, .tab, .backspace, .escape, .delete, + + // Arrow Keys + .arrowUp, .arrowDown, .arrowLeft, .arrowRight, + + // Navigation Keys + .home, .end, .pageUp, .pageDown, .insert, + + // Function Keys (F1-F20) + .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12, + .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20, + + // Modifier Keys + .shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight, + .metaLeft, .metaRight, .capsLock, + + // Punctuation & Symbols + .minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash, + .semicolon, .quote, .comma, .period, .slash, + + // Numpad + .numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5, + .numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract, + .numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual, + .numpadEnter, .numpadComma, + + // Media Keys + .audioVolumeUp, .audioVolumeDown, .audioVolumeMute, + + // International Keys + .intlBackslash, .intlRo, .intlYen, + + // Other + .contextMenu + ] + } + + static var caseDisplayRepresentations: [Ghostty.Input.Key : DisplayRepresentation] = [ + // Letters (A-Z) + .a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J", + .k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T", + .u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z", - // Functional Keys - .altLeft: "Left Alt", - .altRight: "Right Alt", - .backspace: "Backspace", - .capsLock: "Caps Lock", - .contextMenu: "Context Menu", - .controlLeft: "Left Control", - .controlRight: "Right Control", - .enter: "Enter", - .metaLeft: "Left Command", - .metaRight: "Right Command", - .shiftLeft: "Left Shift", - .shiftRight: "Right Shift", + // Numbers (0-9) + .digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4", + .digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9", + + // Common Control Keys .space: "Space", + .enter: "Enter", .tab: "Tab", - .convert: "Convert", - .kanaMode: "Kana Mode", - .nonConvert: "Non Convert", - - // Control Pad Section + .backspace: "Backspace", + .escape: "Escape", .delete: "Delete", - .end: "End", - .help: "Help", - .home: "Home", - .insert: "Insert", - .pageDown: "Page Down", - .pageUp: "Page Up", - // Arrow Pad Section + // Arrow Keys + .arrowUp: "Up Arrow", .arrowDown: "Down Arrow", .arrowLeft: "Left Arrow", .arrowRight: "Right Arrow", - .arrowUp: "Up Arrow", - // Numpad Section + // Navigation Keys + .home: "Home", + .end: "End", + .pageUp: "Page Up", + .pageDown: "Page Down", + .insert: "Insert", + + // Function Keys (F1-F20) + .f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6", + .f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12", + .f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17", + .f18: "F18", .f19: "F19", .f20: "F20", + + // Modifier Keys + .shiftLeft: "Left Shift", + .shiftRight: "Right Shift", + .controlLeft: "Left Control", + .controlRight: "Right Control", + .altLeft: "Left Alt", + .altRight: "Right Alt", + .metaLeft: "Left Command", + .metaRight: "Right Command", + .capsLock: "Caps Lock", + + // Punctuation & Symbols + .minus: "Minus (-)", + .equal: "Equal (=)", + .backquote: "Backtick (`)", + .bracketLeft: "Left Bracket ([)", + .bracketRight: "Right Bracket (])", + .backslash: "Backslash (\\)", + .semicolon: "Semicolon (;)", + .quote: "Quote (')", + .comma: "Comma (,)", + .period: "Period (.)", + .slash: "Slash (/)", + + // Numpad .numLock: "Num Lock", - .numpad0: "Numpad 0", - .numpad1: "Numpad 1", - .numpad2: "Numpad 2", - .numpad3: "Numpad 3", - .numpad4: "Numpad 4", - .numpad5: "Numpad 5", - .numpad6: "Numpad 6", - .numpad7: "Numpad 7", - .numpad8: "Numpad 8", - .numpad9: "Numpad 9", + .numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2", + .numpad3: "Numpad 3", .numpad4: "Numpad 4", .numpad5: "Numpad 5", + .numpad6: "Numpad 6", .numpad7: "Numpad 7", .numpad8: "Numpad 8", .numpad9: "Numpad 9", .numpadAdd: "Numpad Add (+)", - .numpadBackspace: "Numpad Backspace", - .numpadClear: "Numpad Clear", - .numpadClearEntry: "Numpad Clear Entry", - .numpadComma: "Numpad Comma", - .numpadDecimal: "Numpad Decimal", - .numpadDivide: "Numpad Divide (÷)", - .numpadEnter: "Numpad Enter", - .numpadEqual: "Numpad Equal", - .numpadMemoryAdd: "Numpad Memory Add", - .numpadMemoryClear: "Numpad Memory Clear", - .numpadMemoryRecall: "Numpad Memory Recall", - .numpadMemoryStore: "Numpad Memory Store", - .numpadMemorySubtract: "Numpad Memory Subtract", - .numpadMultiply: "Numpad Multiply (×)", - .numpadParenLeft: "Numpad Left Parenthesis", - .numpadParenRight: "Numpad Right Parenthesis", .numpadSubtract: "Numpad Subtract (-)", - .numpadSeparator: "Numpad Separator", - .numpadUp: "Numpad Up", - .numpadDown: "Numpad Down", - .numpadRight: "Numpad Right", - .numpadLeft: "Numpad Left", - .numpadBegin: "Numpad Begin", - .numpadHome: "Numpad Home", - .numpadEnd: "Numpad End", - .numpadInsert: "Numpad Insert", - .numpadDelete: "Numpad Delete", - .numpadPageUp: "Numpad Page Up", - .numpadPageDown: "Numpad Page Down", - - // Function Section - .escape: "Escape", - .f1: "F1", - .f2: "F2", - .f3: "F3", - .f4: "F4", - .f5: "F5", - .f6: "F6", - .f7: "F7", - .f8: "F8", - .f9: "F9", - .f10: "F10", - .f11: "F11", - .f12: "F12", - .f13: "F13", - .f14: "F14", - .f15: "F15", - .f16: "F16", - .f17: "F17", - .f18: "F18", - .f19: "F19", - .f20: "F20", - .f21: "F21", - .f22: "F22", - .f23: "F23", - .f24: "F24", - .f25: "F25", - .fn: "Fn", - .fnLock: "Fn Lock", - .printScreen: "Print Screen", - .scrollLock: "Scroll Lock", - .pause: "Pause", + .numpadMultiply: "Numpad Multiply (×)", + .numpadDivide: "Numpad Divide (÷)", + .numpadDecimal: "Numpad Decimal", + .numpadEqual: "Numpad Equal", + .numpadEnter: "Numpad Enter", + .numpadComma: "Numpad Comma", // Media Keys - .browserBack: "Browser Back", - .browserFavorites: "Browser Favorites", - .browserForward: "Browser Forward", - .browserHome: "Browser Home", - .browserRefresh: "Browser Refresh", - .browserSearch: "Browser Search", - .browserStop: "Browser Stop", - .eject: "Eject", - .launchApp1: "Launch App 1", - .launchApp2: "Launch App 2", - .launchMail: "Launch Mail", - .mediaPlayPause: "Media Play/Pause", - .mediaSelect: "Media Select", - .mediaStop: "Media Stop", - .mediaTrackNext: "Media Next Track", - .mediaTrackPrevious: "Media Previous Track", - .power: "Power", - .sleep: "Sleep", + .audioVolumeUp: "Volume Up", .audioVolumeDown: "Volume Down", .audioVolumeMute: "Volume Mute", - .audioVolumeUp: "Volume Up", - .wakeUp: "Wake Up", - // Legacy, Non-standard, and Special Keys - .copy: "Copy", - .cut: "Cut", - .paste: "Paste" + // International Keys + .intlBackslash: "International Backslash", + .intlRo: "International Ro", + .intlYen: "International Yen", + + // Other + .contextMenu: "Context Menu" ] } diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 10e699c1f..88d3f1d09 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -48,6 +48,20 @@ extension Ghostty { } } + /// Send a key event to the terminal. + /// + /// This sends the full key event including modifiers, action type, and text to the terminal. + /// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal + /// encoding based on the complete key event information. + /// + /// - Parameter event: The key event to send to the terminal + @MainActor + func sendKeyEvent(_ event: Input.KeyEvent) { + event.withCValue { cEvent in + ghostty_surface_key(surface, cEvent) + } + } + /// Perform a keybinding action. /// /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index 491ec86e1..8008e49c2 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -337,7 +337,7 @@ extension Ghostty { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { guard let inspector = self.inspector else { return } - guard let key = Ghostty.Key(keyCode: event.keyCode) else { return } + guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) ghostty_inspector_key(inspector, action, key.cKey, mods) } From 4445a9c63701c52906be2c7a4f987939e79fb237 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 13:49:36 -0700 Subject: [PATCH 14/27] macos: add mouse button intent --- .../Features/App Intents/InputIntent.swift | 58 +++++++++ macos/Sources/Ghostty/Ghostty.Input.swift | 117 +++++++++++++++++- macos/Sources/Ghostty/Ghostty.Surface.swift | 26 ++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 15 +-- 4 files changed, 206 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index 6d3d60d59..56af10ceb 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -95,6 +95,64 @@ struct KeyEventIntent: AppIntent { } } +// MARK: MouseButtonIntent + +/// App intent to trigger a mouse button event. +struct MouseButtonIntent: AppIntent { + static var title: LocalizedStringResource = "Send Mouse Button Event to Terminal" + + @Parameter( + title: "Button", + description: "The mouse button to press or release.", + default: .left + ) + var button: Ghostty.Input.MouseButton + + @Parameter( + title: "Action", + description: "Whether to press or release the button.", + default: .press + ) + var action: Ghostty.Input.MouseState + + @Parameter( + title: "Modifier(s)", + description: "The modifiers to send with the mouse event.", + default: [] + ) + var mods: [KeyEventMods] + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + // Convert KeyEventMods array to Ghostty.Input.Mods + let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in + result.union(mod.ghosttyMod) + } + + let mouseEvent = Ghostty.Input.MouseButtonEvent( + action: action, + button: button, + mods: ghosttyMods + ) + surface.sendMouseButton(mouseEvent) + + return .result() + } +} + // MARK: Mods enum KeyEventMods: String, AppEnum, CaseIterable { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index df93017c7..a2d6b104d 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -215,11 +215,126 @@ extension Ghostty.Input.Action: AppEnum { static var caseDisplayRepresentations: [Ghostty.Input.Action : DisplayRepresentation] = [ .release: "Release", - .press: "Press", + .press: "Press", .repeat: "Repeat" ] } +// MARK: Ghostty.Input.MouseEvent + +extension Ghostty.Input { + /// Represents a mouse input event with button state, button type, and modifier keys. + struct MouseButtonEvent { + let action: MouseState + let button: MouseButton + let mods: Mods + + init( + action: MouseState, + button: MouseButton, + mods: Mods = [] + ) { + self.action = action + self.button = button + self.mods = mods + } + + /// Creates a MouseEvent from C enum values. + /// + /// This initializer converts C-style mouse input enums to Swift types. + /// Returns nil if any of the C enum values are invalid or unsupported. + /// + /// - Parameters: + /// - state: The mouse button state (press/release) + /// - button: The mouse button that was pressed/released + /// - mods: The modifier keys held during the mouse event + init?(state: ghostty_input_mouse_state_e, button: ghostty_input_mouse_button_e, mods: ghostty_input_mods_e) { + // Convert state + switch state { + case GHOSTTY_MOUSE_RELEASE: self.action = .release + case GHOSTTY_MOUSE_PRESS: self.action = .press + default: return nil + } + + // Convert button + switch button { + case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown + case GHOSTTY_MOUSE_LEFT: self.button = .left + case GHOSTTY_MOUSE_RIGHT: self.button = .right + case GHOSTTY_MOUSE_MIDDLE: self.button = .middle + default: return nil + } + + // Convert modifiers + self.mods = Mods(cMods: mods) + } + } +} + +// MARK: Ghostty.Input.MouseState + +extension Ghostty.Input { + /// `ghostty_input_mouse_state_e` + enum MouseState: String, CaseIterable { + case release + case press + + var cMouseState: ghostty_input_mouse_state_e { + switch self { + case .release: GHOSTTY_MOUSE_RELEASE + case .press: GHOSTTY_MOUSE_PRESS + } + } + } +} + +extension Ghostty.Input.MouseState: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse State") + + static var caseDisplayRepresentations: [Ghostty.Input.MouseState : DisplayRepresentation] = [ + .release: "Release", + .press: "Press" + ] +} + +// MARK: Ghostty.Input.MouseButton + +extension Ghostty.Input { + /// `ghostty_input_mouse_button_e` + enum MouseButton: String, CaseIterable { + case unknown + case left + case right + case middle + + var cMouseButton: ghostty_input_mouse_button_e { + switch self { + case .unknown: GHOSTTY_MOUSE_UNKNOWN + case .left: GHOSTTY_MOUSE_LEFT + case .right: GHOSTTY_MOUSE_RIGHT + case .middle: GHOSTTY_MOUSE_MIDDLE + } + } + } +} + +extension Ghostty.Input.MouseButton: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse Button") + + static var caseDisplayRepresentations: [Ghostty.Input.MouseButton : DisplayRepresentation] = [ + .unknown: "Unknown", + .left: "Left", + .right: "Right", + .middle: "Middle" + ] + + static var allCases: [Ghostty.Input.MouseButton] = [ + .left, + .right, + .middle, + ] +} + // MARK: Ghostty.Input.Mods extension Ghostty.Input { diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 88d3f1d09..2cc85f1e4 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -62,6 +62,32 @@ extension Ghostty { } } + /// Whether the terminal has captured mouse input. + /// + /// When the mouse is captured, the terminal application is receiving mouse events + /// directly rather than the host system handling them. This typically occurs when + /// a terminal application enables mouse reporting mode. + @MainActor + var mouseCaptured: Bool { + ghostty_surface_mouse_captured(surface) + } + + /// Send a mouse button event to the terminal. + /// + /// This sends a complete mouse button event including the button state (press/release), + /// which button was pressed, and any modifier keys that were held during the event. + /// The terminal processes this event according to its mouse handling configuration. + /// + /// - Parameter event: The mouse button event to send to the terminal + @MainActor + func sendMouseButton(_ event: Input.MouseButtonEvent) { + ghostty_surface_mouse_button( + surface, + event.action.cMouseState, + event.button.cMouseButton, + event.mods.cMods) + } + /// Perform a keybinding action. /// /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2e7cf499b..d987b80be 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1312,8 +1312,8 @@ extension Ghostty { // In this case, AppKit calls menu BEFORE calling any mouse events. // If mouse capturing is enabled then we never show the context menu // so that we can handle ctrl+left-click in the terminal app. - guard let surface = self.surface else { return nil } - if ghostty_surface_mouse_captured(surface) { + guard let surfaceModel else { return nil } + if surfaceModel.mouseCaptured { return nil } @@ -1323,13 +1323,10 @@ extension Ghostty { // // Note this never sounds a right mouse up event but that's the // same as normal right-click with capturing disabled from AppKit. - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button( - surface, - GHOSTTY_MOUSE_PRESS, - GHOSTTY_MOUSE_RIGHT, - mods - ) + surfaceModel.sendMouseButton(.init( + action: .press, + button: .right, + mods: .init(nsFlags: event.modifierFlags))) default: return nil From bc134016f7d978036103cd3c4532ab9636e445ad Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 14:07:09 -0700 Subject: [PATCH 15/27] macos: move mousePos and mousScroll to Ghostty.Surface --- macos/Sources/Ghostty/Ghostty.Input.swift | 120 ++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Surface.swift | 32 +++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 70 +++++----- 3 files changed, 183 insertions(+), 39 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index a2d6b104d..bbc83c5e5 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -269,6 +269,40 @@ extension Ghostty.Input { self.mods = Mods(cMods: mods) } } + + /// Represents a mouse position/movement event with coordinates and modifier keys. + struct MousePosEvent { + let x: Double + let y: Double + let mods: Mods + + init( + x: Double, + y: Double, + mods: Mods = [] + ) { + self.x = x + self.y = y + self.mods = mods + } + } + + /// Represents a mouse scroll event with scroll deltas and modifier keys. + struct MouseScrollEvent { + let x: Double + let y: Double + let mods: ScrollMods + + init( + x: Double, + y: Double, + mods: ScrollMods = .init(rawValue: 0) + ) { + self.x = x + self.y = y + self.mods = mods + } + } } // MARK: Ghostty.Input.MouseState @@ -335,6 +369,92 @@ extension Ghostty.Input.MouseButton: AppEnum { ] } +// MARK: Ghostty.Input.ScrollMods + +extension Ghostty.Input { + /// `ghostty_input_scroll_mods_t` - Scroll event modifiers + /// + /// This is a packed bitmask that contains precision and momentum information + /// for scroll events, matching the Zig `ScrollMods` packed struct. + struct ScrollMods { + let rawValue: Int32 + + /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse) + var precision: Bool { + rawValue & 0b0000_0001 != 0 + } + + /// The momentum phase of the scroll event for inertial scrolling + var momentum: Momentum { + let momentumBits = (rawValue >> 1) & 0b0000_0111 + return Momentum(rawValue: UInt8(momentumBits)) ?? .none + } + + init(precision: Bool = false, momentum: Momentum = .none) { + var value: Int32 = 0 + if precision { + value |= 0b0000_0001 + } + value |= Int32(momentum.rawValue) << 1 + self.rawValue = value + } + + init(rawValue: Int32) { + self.rawValue = rawValue + } + + var cScrollMods: ghostty_input_scroll_mods_t { + rawValue + } + } +} + +// MARK: Ghostty.Input.Momentum + +extension Ghostty.Input { + /// `ghostty_input_mouse_momentum_e` - Momentum phase for scroll events + enum Momentum: UInt8, CaseIterable { + case none = 0 + case began = 1 + case stationary = 2 + case changed = 3 + case ended = 4 + case cancelled = 5 + case mayBegin = 6 + + var cMomentum: ghostty_input_mouse_momentum_e { + switch self { + case .none: GHOSTTY_MOUSE_MOMENTUM_NONE + case .began: GHOSTTY_MOUSE_MOMENTUM_BEGAN + case .stationary: GHOSTTY_MOUSE_MOMENTUM_STATIONARY + case .changed: GHOSTTY_MOUSE_MOMENTUM_CHANGED + case .ended: GHOSTTY_MOUSE_MOMENTUM_ENDED + case .cancelled: GHOSTTY_MOUSE_MOMENTUM_CANCELLED + case .mayBegin: GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN + } + } + } +} + +#if canImport(AppKit) +import AppKit + +extension Ghostty.Input.Momentum { + /// Create a Momentum from an NSEvent.Phase + init(_ phase: NSEvent.Phase) { + switch phase { + case .began: self = .began + case .stationary: self = .stationary + case .changed: self = .changed + case .ended: self = .ended + case .cancelled: self = .cancelled + case .mayBegin: self = .mayBegin + default: self = .none + } + } +} +#endif + // MARK: Ghostty.Input.Mods extension Ghostty.Input { diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift index 2cc85f1e4..c7198e147 100644 --- a/macos/Sources/Ghostty/Ghostty.Surface.swift +++ b/macos/Sources/Ghostty/Ghostty.Surface.swift @@ -88,6 +88,38 @@ extension Ghostty { event.mods.cMods) } + /// Send a mouse position event to the terminal. + /// + /// This reports the current mouse position to the terminal, which may be used + /// for mouse tracking, hover effects, or other position-dependent features. + /// The terminal will only receive these events if mouse reporting is enabled. + /// + /// - Parameter event: The mouse position event to send to the terminal + @MainActor + func sendMousePos(_ event: Input.MousePosEvent) { + ghostty_surface_mouse_pos( + surface, + event.x, + event.y, + event.mods.cMods) + } + + /// Send a mouse scroll event to the terminal. + /// + /// This sends scroll wheel input to the terminal with delta values for both + /// horizontal and vertical scrolling, along with precision and momentum information. + /// The terminal processes this according to its scroll handling configuration. + /// + /// - Parameter event: The mouse scroll event to send to the terminal + @MainActor + func sendMouseScroll(_ event: Input.MouseScrollEvent) { + ghostty_surface_mouse_scroll( + surface, + event.x, + event.y, + event.mods.cScrollMods) + } + /// Perform a keybinding action. /// /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d987b80be..83a8da29c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -808,19 +808,23 @@ extension Ghostty { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // On mouse enter we need to reset our cursor position. This is // super important because we set it to -1/-1 on mouseExit and // lots of mouse logic (i.e. whether to send mouse reports) depend // on the position being in the viewport if it is. let pos = self.convert(event.locationInWindow, from: nil) - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: pos.x, + y: frame.height - pos.y, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) } override func mouseExited(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit // this because we get mouse drag events even if we've already @@ -830,17 +834,25 @@ extension Ghostty { } // Negative values indicate cursor has left the viewport - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, -1, -1, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: -1, + y: -1, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) } override func mouseMoved(with event: NSEvent) { - guard let surface = self.surface else { return } + guard let surfaceModel else { return } // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) + let mouseEvent = Ghostty.Input.MousePosEvent( + x: pos.x, + y: frame.height - pos.y, + mods: .init(nsFlags: event.modifierFlags) + ) + surfaceModel.sendMousePos(mouseEvent) // Handle focus-follows-mouse if let window, @@ -866,16 +878,13 @@ extension Ghostty { } override func scrollWheel(with event: NSEvent) { - guard let surface = self.surface else { return } - - // Builds up the "input.ScrollMods" bitmask - var mods: Int32 = 0 + guard let surfaceModel else { return } var x = event.scrollingDeltaX var y = event.scrollingDeltaY - if event.hasPreciseScrollingDeltas { - mods = 1 - + let precision = event.hasPreciseScrollingDeltas + + if precision { // We do a 2x speed multiplier. This is subjective, it "feels" better to me. x *= 2; y *= 2; @@ -883,29 +892,12 @@ extension Ghostty { // TODO(mitchellh): do we have to scale the x/y here by window scale factor? } - // Determine our momentum value - var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE - switch (event.momentumPhase) { - case .began: - momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN - case .stationary: - momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY - case .changed: - momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED - case .ended: - momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED - case .cancelled: - momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED - case .mayBegin: - momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN - default: - break - } - - // Pack our momentum value into the mods bitmask - mods |= Int32(momentum.rawValue) << 1 - - ghostty_surface_mouse_scroll(surface, x, y, mods) + let scrollEvent = Ghostty.Input.MouseScrollEvent( + x: x, + y: y, + mods: .init(precision: precision, momentum: .init(event.momentumPhase)) + ) + surfaceModel.sendMouseScroll(scrollEvent) } override func pressureChange(with event: NSEvent) { From 2df301e2fb6abd5d37fef3f7d8808d24977ad89e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 14:14:09 -0700 Subject: [PATCH 16/27] macos: mouse pos and scroll intents --- .../Features/App Intents/InputIntent.swift | 116 ++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Input.swift | 14 +++ 2 files changed, 130 insertions(+) diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index 56af10ceb..b8c248fe3 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -153,6 +153,122 @@ struct MouseButtonIntent: AppIntent { } } +/// App intent to send a mouse position event. +struct MousePosIntent: AppIntent { + static var title: LocalizedStringResource = "Send Mouse Position Event to Terminal" + static var description = IntentDescription("Send a mouse position event to the terminal. This reports the cursor position for mouse tracking.") + + @Parameter( + title: "X Position", + description: "The horizontal position of the mouse cursor in pixels.", + default: 0 + ) + var x: Double + + @Parameter( + title: "Y Position", + description: "The vertical position of the mouse cursor in pixels.", + default: 0 + ) + var y: Double + + @Parameter( + title: "Modifier(s)", + description: "The modifiers to send with the mouse position event.", + default: [] + ) + var mods: [KeyEventMods] + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + // Convert KeyEventMods array to Ghostty.Input.Mods + let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in + result.union(mod.ghosttyMod) + } + + let mousePosEvent = Ghostty.Input.MousePosEvent( + x: x, + y: y, + mods: ghosttyMods + ) + surface.sendMousePos(mousePosEvent) + + return .result() + } +} + +/// App intent to send a mouse scroll event. +struct MouseScrollIntent: AppIntent { + static var title: LocalizedStringResource = "Send Mouse Scroll Event to Terminal" + static var description = IntentDescription("Send a mouse scroll event to the terminal with configurable precision and momentum.") + + @Parameter( + title: "X Scroll Delta", + description: "The horizontal scroll amount.", + default: 0 + ) + var x: Double + + @Parameter( + title: "Y Scroll Delta", + description: "The vertical scroll amount.", + default: 0 + ) + var y: Double + + @Parameter( + title: "High Precision", + description: "Whether this is a high-precision scroll event (e.g., from trackpad).", + default: false + ) + var precision: Bool + + @Parameter( + title: "Momentum Phase", + description: "The momentum phase for inertial scrolling.", + default: Ghostty.Input.Momentum.none + ) + var momentum: Ghostty.Input.Momentum + + @Parameter( + title: "Terminal", + description: "The terminal to scope this action to." + ) + var terminal: TerminalEntity + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = [.background, .foreground] + + @MainActor + func perform() async throws -> some IntentResult { + guard let surface = terminal.surfaceModel else { + throw GhosttyIntentError.surfaceNotFound + } + + let scrollEvent = Ghostty.Input.MouseScrollEvent( + x: x, + y: y, + mods: .init(precision: precision, momentum: momentum) + ) + surface.sendMouseScroll(scrollEvent) + + return .result() + } +} + // MARK: Mods enum KeyEventMods: String, AppEnum, CaseIterable { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index bbc83c5e5..e05911c06 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -436,6 +436,20 @@ extension Ghostty.Input { } } +extension Ghostty.Input.Momentum: AppEnum { + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum") + + static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [ + .none: "None", + .began: "Began", + .stationary: "Stationary", + .changed: "Changed", + .ended: "Ended", + .cancelled: "Cancelled", + .mayBegin: "May Begin" + ] +} + #if canImport(AppKit) import AppKit From 0a27aef508ceb5b3376a77ac9fbb6dc6f30020dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 14:19:37 -0700 Subject: [PATCH 17/27] README: note Xcode 26 requirement --- .github/workflows/test.yml | 41 -------------------------------------- README.md | 22 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2eca0a41e..4d09603f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,6 @@ jobs: - build-nix - build-snap - build-macos - - build-macos-sequoia-stable - build-macos-tahoe - build-macos-matrix - build-windows @@ -310,46 +309,6 @@ jobs: cd macos xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-sequoia-stable: - runs-on: namespace-profile-ghostty-macos-sequoia - needs: test - steps: - - name: Checkout code - uses: actions/checkout@v4 - - # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@v31 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v16 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - - name: Xcode Select - run: sudo xcode-select -s /Applications/Xcode_16.4.app - - - name: get the Zig deps - id: deps - run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT - - # GhosttyKit is the framework that is built from Zig for our native - # Mac app to access. - - name: Build GhosttyKit - run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }} - - # The native app is built with native Xcode tooling. This also does - # codesigning. IMPORTANT: this must NOT run in a Nix environment. - # Nix breaks xcodebuild so this has to be run outside. - - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty - - # Build the iOS target without code signing just to verify it works. - - name: Build Ghostty iOS - run: | - cd macos - xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" - build-macos-tahoe: runs-on: namespace-profile-ghostty-macos-tahoe needs: test diff --git a/README.md b/README.md index d5c9dba02..b59964e61 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,28 @@ macOS users don't require any additional dependencies. > source tarballs, see the > [website](http://ghostty.org/docs/install/build). +### Xcode Version and SDKs + +Building the Ghostty macOS app requires that Xcode, the macOS SDK, +and the iOS SDK are all installed. + +A common issue is that the incorrect version of Xcode is either +installed or selected. Use the `xcode-select` command to +ensure that the correct version of Xcode is selected: + +```shell-session +sudo xcode-select --switch /Applications/Xcode-beta.app +``` + +> [!IMPORTANT] +> +> Main branch development of Ghostty is preparing for the next major +> macOS release, Tahoe (macOS 26). Therefore, the main branch requires +> **Xcode 26 and the macOS 26 SDK**. +> +> You do not need to be running on macOS 26 to build Ghostty, you can +> still use Xcode 26 beta on macOS 15 stable. + ### Linting #### Prettier From f096675eaf389871e53140d0037a6ce208307654 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Jun 2025 20:00:24 -0700 Subject: [PATCH 18/27] macos: Close Terminal Intent --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../App Intents/CloseTerminalIntent.swift | 38 ++++++++++ .../QuickTerminalController.swift | 6 +- .../Terminal/BaseTerminalController.swift | 73 +++++++++++-------- .../Terminal/TerminalController.swift | 4 +- 5 files changed, 88 insertions(+), 37 deletions(-) create mode 100644 macos/Sources/Features/App Intents/CloseTerminalIntent.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index bbb34820f..acf4b0e43 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; + A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -149,6 +150,7 @@ 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; + A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; @@ -625,6 +627,7 @@ isa = PBXGroup; children = ( A5E408412E0453370035FEAC /* Entities */, + A511940E2E050590007258CC /* CloseTerminalIntent.swift */, A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */, @@ -793,6 +796,7 @@ A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, + A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */, A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */, A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift new file mode 100644 index 000000000..18079650b --- /dev/null +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -0,0 +1,38 @@ +import AppKit +import AppIntents +import GhosttyKit + +struct CloseTerminalIntent: AppIntent { + static var title: LocalizedStringResource = "Close Terminal" + static var description = IntentDescription("Close an existing terminal.") + + @Parameter( + title: "Terminal", + description: "The terminal to close.", + ) + var terminal: TerminalEntity + + @Parameter( + title: "Command", + description: "Command to execute instead of the default shell.", + default: true + ) + var confirm: Bool + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + @MainActor + func perform() async throws -> some IntentResult { + guard let surfaceView = terminal.surfaceView else { + throw GhosttyIntentError.surfaceNotFound + } + + guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { + return .result() + } + + controller.closeSurface(surfaceView, withConfirmation: confirm) + return .result() + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 28dea9579..80b0c9413 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -218,19 +218,19 @@ class QuickTerminalController: BaseTerminalController { } } - override func closeSurfaceNode( + override func closeSurface( _ node: SplitTree.Node, withConfirmation: Bool = true ) { // If this isn't the root then we're dealing with a split closure. if surfaceTree.root != node { - super.closeSurfaceNode(node, withConfirmation: withConfirmation) + super.closeSurface(node, withConfirmation: withConfirmation) return } // If this isn't a final leaf then we're dealing with a split closure guard case .leaf(let surface) = node else { - super.closeSurfaceNode(node, withConfirmation: withConfirmation) + super.closeSurface(node, withConfirmation: withConfirmation) return } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 81b7d32b6..c93a9450d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -300,6 +300,46 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + /// Close a surface from a view. + func closeSurface( + _ view: Ghostty.SurfaceView, + withConfirmation: Bool = true + ) { + guard let node = surfaceTree.root?.node(view: view) else { return } + closeSurface(node, withConfirmation: withConfirmation) + } + + /// Close a surface node (which may contain splits), requesting confirmation if necessary. + /// + /// This will also insert the proper undo stack information in. + func closeSurface( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // This node must be part of our tree + guard surfaceTree.contains(node) else { return } + + // If the child process is not alive, then we exit immediately + guard withConfirmation else { + removeSurfaceNode(node) + return + } + + // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog + // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that + // confirmationDialog allows the user to Cmd-W close the alert, but when doing + // so SwiftUI does not update any of the bindings to note that window is no longer + // being shown, and provides no callback to detect this. + confirmClose( + messageText: "Close Terminal?", + informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." + ) { [weak self] in + if let self { + self.removeSurfaceNode(node) + } + } + } + // MARK: Split Tree Management /// Find the next surface to focus when a node is being closed. @@ -460,42 +500,11 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidCloseSurface(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let node = surfaceTree.root?.node(view: target) else { return } - closeSurfaceNode( + closeSurface( node, withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) } - /// Close a surface node (which may contain splits), requesting confirmation if necessary. - /// - /// This will also insert the proper undo stack information in. - func closeSurfaceNode( - _ node: SplitTree.Node, - withConfirmation: Bool = true - ) { - // This node must be part of our tree - guard surfaceTree.contains(node) else { return } - - // If the child process is not alive, then we exit immediately - guard withConfirmation else { - removeSurfaceNode(node) - return - } - - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog - // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that - // confirmationDialog allows the user to Cmd-W close the alert, but when doing - // so SwiftUI does not update any of the bindings to note that window is no longer - // being shown, and provides no callback to detect this. - confirmClose( - messageText: "Close Terminal?", - informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." - ) { [weak self] in - if let self { - self.removeSurfaceNode(node) - } - } - } - @objc private func ghosttyDidNewSplit(_ notification: Notification) { // The target must be within our tree guard let oldView = notification.object as? Ghostty.SurfaceView else { return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index a224c9248..77eb079ad 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -519,13 +519,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } /// This is called anytime a node in the surface tree is being removed. - override func closeSurfaceNode( + override func closeSurface( _ node: SplitTree.Node, withConfirmation: Bool = true ) { // If this isn't the root then we're dealing with a split closure. if surfaceTree.root != node { - super.closeSurfaceNode(node, withConfirmation: withConfirmation) + super.closeSurface(node, withConfirmation: withConfirmation) return } From 2c1e83ba2fd621d532ea0e7e4746f3f7ae19c069 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Jun 2025 07:03:40 -0700 Subject: [PATCH 19/27] macos: intent to open quick terminal --- macos/Ghostty.xcodeproj/project.pbxproj | 4 +++ macos/Sources/App/macOS/AppDelegate.swift | 15 ++++------ .../App Intents/Entities/TerminalEntity.swift | 27 +++++++++++++++++- .../App Intents/QuickTerminalIntent.swift | 28 +++++++++++++++++++ .../QuickTerminalController.swift | 6 +++- 5 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Features/App Intents/QuickTerminalIntent.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index acf4b0e43..6b0cfd6f8 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; + A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -151,6 +152,7 @@ 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; + A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; @@ -630,6 +632,7 @@ A511940E2E050590007258CC /* CloseTerminalIntent.swift */, A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */, A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */, + A51194102E05A480007258CC /* QuickTerminalIntent.swift */, A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */, A5E408462E0485270035FEAC /* InputIntent.swift */, A5E408442E0483F80035FEAC /* KeybindIntent.swift */, @@ -806,6 +809,7 @@ A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, + A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7336f18d6..4ffb9efa4 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -92,7 +92,10 @@ class AppDelegate: NSObject, lazy var undoManager = ExpiringUndoManager() /// Our quick terminal. This starts out uninitialized and only initializes if used. - private var quickController: QuickTerminalController? = nil + private(set) lazy var quickController = QuickTerminalController( + ghostty, + position: derivedConfig.quickTerminalPosition + ) /// Manages updates let updaterController: SPUStandardUpdaterController @@ -286,7 +289,7 @@ class AppDelegate: NSObject, // NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it // here because I don't want to remove it in a patch release cycle but we should // target removing it soon. - if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) { + if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } @@ -919,14 +922,6 @@ class AppDelegate: NSObject, } @IBAction func toggleQuickTerminal(_ sender: Any) { - if quickController == nil { - quickController = QuickTerminalController( - ghostty, - position: derivedConfig.quickTerminalPosition - ) - } - - guard let quickController = self.quickController else { return } quickController.toggle() } diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index 750512d02..1fb69f1f8 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -11,6 +11,9 @@ struct TerminalEntity: AppEntity { @Property(title: "Working Directory") var workingDirectory: String? + @Property(title: "Kind") + var kind: Kind + @MainActor @DeferredProperty(title: "Full Contents") @available(macOS 26.0, *) @@ -67,6 +70,27 @@ struct TerminalEntity: AppEntity { self.title = view.title self.workingDirectory = view.pwd self.screenshot = view.screenshot() + + // Determine the kind based on the window controller type + if view.window?.windowController is QuickTerminalController { + self.kind = .quick + } else { + self.kind = .normal + } + } +} + +extension TerminalEntity { + enum Kind: String, AppEnum { + case normal + case quick + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind") + + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .normal: .init(title: "Normal"), + .quick: .init(title: "Quick") + ] } } @@ -101,7 +125,8 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { @MainActor var all: [Ghostty.SurfaceView] { - // Find all of our terminal windows (includes quick terminal) + // Find all of our terminal windows. This will include the quick terminal + // but only if it was previously opened. let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift new file mode 100644 index 000000000..ee2761217 --- /dev/null +++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift @@ -0,0 +1,28 @@ +import AppKit +import AppIntents + +struct QuickTerminalIntent: AppIntent { + static var title: LocalizedStringResource = "Open the Quick Terminal" + static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.") + + @available(macOS 26.0, *) + static var supportedModes: IntentModes = .background + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> { + guard let delegate = NSApp.delegate as? AppDelegate else { + throw GhosttyIntentError.appUnavailable + } + + // This is safe to call even if it is already shown. + let c = delegate.quickController + c.animateIn() + + // Grab all our terminals + let terminals = c.surfaceTree.root?.leaves().map { + TerminalEntity($0) + } ?? [] + + return .result(value: terminals) + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 80b0c9413..3bd8bc18f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -42,7 +42,11 @@ class QuickTerminalController: BaseTerminalController { ) { self.position = position self.derivedConfig = DerivedConfig(ghostty.config) - super.init(ghostty, baseConfig: base, surfaceTree: tree) + + // Important detail here: we initialize with an empty surface tree so + // that we don't start a terminal process. This gets started when the + // first terminal is shown in `animateIn`. + super.init(ghostty, baseConfig: base, surfaceTree: .init()) // Setup our notifications for behaviors let center = NotificationCenter.default From e6c24fbf0a634a28d8c079948d982636f00cf197 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Jun 2025 07:22:12 -0700 Subject: [PATCH 20/27] macos: remove confirmation option for close terminal --- .../Features/App Intents/CloseTerminalIntent.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift index 18079650b..4de415494 100644 --- a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -12,13 +12,6 @@ struct CloseTerminalIntent: AppIntent { ) var terminal: TerminalEntity - @Parameter( - title: "Command", - description: "Command to execute instead of the default shell.", - default: true - ) - var confirm: Bool - @available(macOS 26.0, *) static var supportedModes: IntentModes = .background @@ -32,7 +25,7 @@ struct CloseTerminalIntent: AppIntent { return .result() } - controller.closeSurface(surfaceView, withConfirmation: confirm) + controller.closeSurface(surfaceView, withConfirmation: false) return .result() } } From f8bc9b547c2a7e37de80cad6709936852126c46e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Jun 2025 10:09:01 -0700 Subject: [PATCH 21/27] macos: support env vars for surface config, clean up surface config --- include/ghostty.h | 10 +- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/Ghostty/SurfaceView.swift | 72 ++++++++++++--- .../Sources/Ghostty/SurfaceView_AppKit.swift | 6 +- macos/Sources/Ghostty/SurfaceView_UIKit.swift | 6 +- .../Helpers/Extensions/Array+Extension.swift | 25 +++++ .../Extensions/Optional+Extension.swift | 10 ++ src/apprt/embedded.zig | 92 ++++++++++++------- src/config/Config.zig | 5 + 9 files changed, 180 insertions(+), 50 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/Optional+Extension.swift diff --git a/include/ghostty.h b/include/ghostty.h index fc2c915cb..0c5a63448 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -385,6 +385,11 @@ typedef struct { bool rectangle; } ghostty_selection_s; +typedef struct { + const char* key; + const char* value; +} ghostty_env_var_s; + typedef struct { void* nsview; } ghostty_platform_macos_s; @@ -406,6 +411,8 @@ typedef struct { float font_size; const char* working_directory; const char* command; + ghostty_env_var_s* env_vars; + size_t env_var_count; } ghostty_surface_config_s; typedef struct { @@ -807,7 +814,8 @@ void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); ghostty_surface_config_s ghostty_surface_config_new(); -ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); +ghostty_surface_t ghostty_surface_new(ghostty_app_t, + const ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6b0cfd6f8..a64e6038e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; }; + A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -153,6 +154,7 @@ A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = ""; }; + A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; @@ -506,6 +508,7 @@ A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, + A51194122E05D003007258CC /* Optional+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, @@ -786,6 +789,7 @@ CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, + A51194132E05D006007258CC /* Optional+Extension.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 371e4ff41..2f0623b79 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -418,18 +418,36 @@ extension Ghostty { /// Explicit command to set var command: String? = nil + + /// Environment variables to set for the terminal + var environmentVariables: [String: String] = [:] init() {} init(from config: ghostty_surface_config_s) { self.fontSize = config.font_size - self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) - self.command = String.init(cString: config.command, encoding: .utf8) + if let workingDirectory = config.working_directory { + self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8) + } + if let command = config.command { + self.command = String.init(cString: command, encoding: .utf8) + } + + // Convert the C env vars to Swift dictionary + if config.env_var_count > 0, let envVars = config.env_vars { + for i in 0.. ghostty_surface_config_s { + /// Provides a C-compatible ghostty configuration within a closure. The configuration + /// and all its string pointers are only valid within the closure. + func withCValue(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T { var config = ghostty_surface_config_new() config.userdata = Unmanaged.passUnretained(view).toOpaque() #if os(macOS) @@ -438,7 +456,6 @@ extension Ghostty { nsview: Unmanaged.passUnretained(view).toOpaque() )) config.scale_factor = NSScreen.main!.backingScaleFactor - #elseif os(iOS) config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( @@ -453,15 +470,42 @@ extension Ghostty { #error("unsupported target") #endif - if let fontSize = fontSize { config.font_size = fontSize } - if let workingDirectory = workingDirectory { - config.working_directory = (workingDirectory as NSString).utf8String - } - if let command = command { - config.command = (command as NSString).utf8String - } + // Zero is our default value that means to inherit the font size. + config.font_size = fontSize ?? 0 - return config + // Use withCString to ensure strings remain valid for the duration of the closure + return try workingDirectory.withCString { cWorkingDir in + config.working_directory = cWorkingDir + + return try command.withCString { cCommand in + config.command = cCommand + + // Convert dictionary to arrays for easier processing + let keys = Array(environmentVariables.keys) + let values = Array(environmentVariables.values) + + // Create C strings for all keys and values + return try keys.withCStrings { keyCStrings in + return try values.withCStrings { valueCStrings in + // Create array of ghostty_env_var_s + var envVars = Array() + envVars.reserveCapacity(environmentVariables.count) + for i in 0..(_ body: ([UnsafePointer?]) throws -> T) rethrows -> T { + // Handle empty array + if isEmpty { + return try body([]) + } + + // Recursive helper to process strings + func helper(index: Int, accumulated: [UnsafePointer?], body: ([UnsafePointer?]) throws -> T) rethrows -> T { + if index == count { + return try body(accumulated) + } else { + return try self[index].withCString { cStr in + var newAccumulated = accumulated + newAccumulated.append(cStr) + return try helper(index: index + 1, accumulated: newAccumulated, body: body) + } + } + } + + return try helper(index: 0, accumulated: [], body: body) + } +} diff --git a/macos/Sources/Helpers/Extensions/Optional+Extension.swift b/macos/Sources/Helpers/Extensions/Optional+Extension.swift new file mode 100644 index 000000000..a844c0fe9 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Optional+Extension.swift @@ -0,0 +1,10 @@ +extension Optional where Wrapped == String { + /// Executes a closure with a C string pointer, handling nil gracefully. + func withCString(_ body: (UnsafePointer?) throws -> T) rethrows -> T { + if let string = self { + return try string.withCString(body) + } else { + return try body(nil) + } + } +} diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 01e287d16..02f143985 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -376,6 +376,14 @@ pub const PlatformTag = enum(c_int) { ios = 2, }; +pub const EnvVar = extern struct { + /// The name of the environment variable. + key: [*:0]const u8, + + /// The value of the environment variable. + value: [*:0]const u8, +}; + pub const Surface = struct { app: *App, platform: Platform, @@ -407,7 +415,7 @@ pub const Surface = struct { font_size: f32 = 0, /// The working directory to load into. - working_directory: [*:0]const u8 = "", + working_directory: ?[*:0]const u8 = null, /// The command to run in the new surface. If this is set then /// the "wait-after-command" option is also automatically set to true, @@ -417,7 +425,11 @@ pub const Surface = struct { /// despite Ghostty allowing directly executed commands via config. /// This is a legacy thing and we should probably change it in the /// future once we have a concrete use case. - command: [*:0]const u8 = "", + command: ?[*:0]const u8 = null, + + /// Extra environment variables to set for the surface. + env_vars: ?[*]EnvVar = null, + env_var_count: usize = 0, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -443,41 +455,59 @@ pub const Surface = struct { defer config.deinit(); // If we have a working directory from the options then we set it. - const wd = std.mem.sliceTo(opts.working_directory, 0); - if (wd.len > 0) wd: { - var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { - log.warn( - "error opening requested working directory dir={s} err={}", - .{ wd, err }, - ); - break :wd; - }; - defer dir.close(); + if (opts.working_directory) |c_wd| { + const wd = std.mem.sliceTo(c_wd, 0); + if (wd.len > 0) wd: { + var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { + log.warn( + "error opening requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; + defer dir.close(); - const stat = dir.stat() catch |err| { - log.warn( - "failed to stat requested working directory dir={s} err={}", - .{ wd, err }, - ); - break :wd; - }; + const stat = dir.stat() catch |err| { + log.warn( + "failed to stat requested working directory dir={s} err={}", + .{ wd, err }, + ); + break :wd; + }; - if (stat.kind != .directory) { - log.warn( - "requested working directory is not a directory dir={s}", - .{wd}, - ); - break :wd; + if (stat.kind != .directory) { + log.warn( + "requested working directory is not a directory dir={s}", + .{wd}, + ); + break :wd; + } + + config.@"working-directory" = wd; } - - config.@"working-directory" = wd; } // If we have a command from the options then we set it. - const cmd = std.mem.sliceTo(opts.command, 0); - if (cmd.len > 0) { - config.command = .{ .shell = cmd }; - config.@"wait-after-command" = true; + if (opts.command) |c_command| { + const cmd = std.mem.sliceTo(c_command, 0); + if (cmd.len > 0) { + config.command = .{ .shell = cmd }; + config.@"wait-after-command" = true; + } + } + + // Apply any environment variables that were requested. + if (opts.env_var_count > 0) { + const alloc = config.arenaAlloc(); + for (opts.env_vars.?[0..opts.env_var_count]) |env_var| { + const key = std.mem.sliceTo(env_var.key, 0); + const value = std.mem.sliceTo(env_var.value, 0); + try config.env.map.put( + alloc, + try alloc.dupeZ(u8, key), + try alloc.dupeZ(u8, value), + ); + } } // Initialize our surface right away. We're given a view that is diff --git a/src/config/Config.zig b/src/config/Config.zig index 2df66ba45..e9370d9b3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -3004,6 +3004,11 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void { } } +/// Get the arena allocator associated with the configuration. +pub fn arenaAlloc(self: *Config) Allocator { + return self._arena.?.allocator(); +} + /// Change the state of conditionals and reload the configuration /// based on the new state. This returns a new configuration based /// on the new state. The caller must free the old configuration if they From 027171bd5db7f1ffd199fffeed4e8ef41f7a30d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Jun 2025 10:40:33 -0700 Subject: [PATCH 22/27] macos: can set env vars on new terminal --- .../Features/App Intents/NewTerminalIntent.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 55f33bd46..444f3d7c0 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -30,6 +30,13 @@ struct NewTerminalIntent: AppIntent { ) var workingDirectory: IntentFile? + @Parameter( + title: "Environment Variables", + description: "Environment variables in `KEY=VALUE` format.", + default: [] + ) + var env: [String] + @Parameter( title: "Parent Terminal", description: "The terminal to inherit the base configuration from." @@ -58,6 +65,15 @@ struct NewTerminalIntent: AppIntent { config.workingDirectory = dir.path(percentEncoded: false) } + // Parse environment variables from KEY=VALUE format + for envVar in env { + if let separatorIndex = envVar.firstIndex(of: "=") { + let key = String(envVar[.. Date: Fri, 20 Jun 2025 11:06:05 -0700 Subject: [PATCH 23/27] macos: intents all ask for permission --- macos/Ghostty.xcodeproj/project.pbxproj | 8 + .../App Intents/CloseTerminalIntent.swift | 4 + .../App Intents/CommandPaletteIntent.swift | 4 + .../GetTerminalDetailsIntent.swift | 4 + .../App Intents/GhosttyIntentError.swift | 2 + .../Features/App Intents/InputIntent.swift | 20 +++ .../App Intents/IntentPermission.swift | 37 ++++ .../Features/App Intents/KeybindIntent.swift | 4 + .../App Intents/NewTerminalIntent.swift | 3 + .../App Intents/QuickTerminalIntent.swift | 4 + macos/Sources/Helpers/PermissionRequest.swift | 162 ++++++++++++++++++ 11 files changed, 252 insertions(+) create mode 100644 macos/Sources/Features/App Intents/IntentPermission.swift create mode 100644 macos/Sources/Helpers/PermissionRequest.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a64e6038e..a203ad682 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; }; A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; }; A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; + A51194172E05D964007258CC /* PermissionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194162E05D95E007258CC /* PermissionRequest.swift */; }; + A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194182E05DFBB007258CC /* IntentPermission.swift */; }; A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; @@ -155,6 +157,8 @@ A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = ""; }; A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = ""; }; A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = ""; }; + A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = ""; }; + A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = ""; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = ""; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = ""; }; @@ -355,6 +359,7 @@ A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, + A51194162E05D95E007258CC /* PermissionRequest.swift */, A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, @@ -640,6 +645,7 @@ A5E408462E0485270035FEAC /* InputIntent.swift */, A5E408442E0483F80035FEAC /* KeybindIntent.swift */, A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */, + A51194182E05DFBB007258CC /* IntentPermission.swift */, ); path = "App Intents"; sourceTree = ""; @@ -821,6 +827,8 @@ A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, + A51194172E05D964007258CC /* PermissionRequest.swift in Sources */, + A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */, A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift index 4de415494..923d22c97 100644 --- a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift @@ -17,6 +17,10 @@ struct CloseTerminalIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surfaceView = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift index 2c1ff3386..fa983054b 100644 --- a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift +++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift @@ -24,6 +24,10 @@ struct CommandPaletteIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift index 5c41908f4..1cbaa9d68 100644 --- a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift +++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift @@ -26,6 +26,10 @@ struct GetTerminalDetailsIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + switch detail { case .title: return .result(value: terminal.title) case .workingDirectory: return .result(value: terminal.workingDirectory) diff --git a/macos/Sources/Features/App Intents/GhosttyIntentError.swift b/macos/Sources/Features/App Intents/GhosttyIntentError.swift index 34a0636d9..635250f72 100644 --- a/macos/Sources/Features/App Intents/GhosttyIntentError.swift +++ b/macos/Sources/Features/App Intents/GhosttyIntentError.swift @@ -1,11 +1,13 @@ enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible { case appUnavailable case surfaceNotFound + case permissionDenied var localizedStringResource: LocalizedStringResource { switch self { case .appUnavailable: return "The Ghostty app isn't properly initialized." case .surfaceNotFound: return "The terminal no longer exists." + case .permissionDenied: return "Ghostty doesn't allow Shortcuts." } } } diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift index b8c248fe3..17c97fbbb 100644 --- a/macos/Sources/Features/App Intents/InputIntent.swift +++ b/macos/Sources/Features/App Intents/InputIntent.swift @@ -29,6 +29,10 @@ struct InputTextIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -75,6 +79,10 @@ struct KeyEventIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -133,6 +141,10 @@ struct MouseButtonIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -190,6 +202,10 @@ struct MousePosIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } @@ -254,6 +270,10 @@ struct MouseScrollIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/IntentPermission.swift b/macos/Sources/Features/App Intents/IntentPermission.swift new file mode 100644 index 000000000..e02c4591d --- /dev/null +++ b/macos/Sources/Features/App Intents/IntentPermission.swift @@ -0,0 +1,37 @@ +/// Requests permission for Shortcuts app to interact with Ghostty +/// +/// This function displays a permission dialog asking the user to allow Shortcuts +/// to interact with Ghostty. The permission is automatically cached for 10 minutes +/// if the user selects "Allow", meaning subsequent intent calls won't show the dialog +/// again during that time period. +/// +/// The permission uses a shared UserDefaults key across all intents, so granting +/// permission for one intent allows all Ghostty intents to execute without additional +/// prompts for the duration of the cache period. +/// +/// - Returns: `true` if permission is granted, `false` if denied +/// +/// ## Usage +/// Add this check at the beginning of any App Intent's `perform()` method: +/// ```swift +/// @MainActor +/// func perform() async throws -> some IntentResult { +/// guard await requestIntentPermission() else { +/// throw GhosttyIntentError.permissionDenied +/// } +/// // ... continue with intent implementation +/// } +/// ``` +func requestIntentPermission() async -> Bool { + await withCheckedContinuation { continuation in + Task { @MainActor in + PermissionRequest.show( + "org.mitchellh.ghostty.shortcutsPermission", + message: "Allow Shortcuts to interact with Ghostty for the next 10 minutes?", + allowDuration: .seconds(600), + ) { response in + continuation.resume(returning: response) + } + } + } +} diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift index adeb64331..b31da4a50 100644 --- a/macos/Sources/Features/App Intents/KeybindIntent.swift +++ b/macos/Sources/Features/App Intents/KeybindIntent.swift @@ -21,6 +21,10 @@ struct KeybindIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let surface = terminal.surfaceModel else { throw GhosttyIntentError.surfaceNotFound } diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 444f3d7c0..3c36bed87 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -51,6 +51,9 @@ struct NewTerminalIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } guard let appDelegate = NSApp.delegate as? AppDelegate else { throw GhosttyIntentError.appUnavailable } diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift index ee2761217..2e6c9850c 100644 --- a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift @@ -10,6 +10,10 @@ struct QuickTerminalIntent: AppIntent { @MainActor func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> { + guard await requestIntentPermission() else { + throw GhosttyIntentError.permissionDenied + } + guard let delegate = NSApp.delegate as? AppDelegate else { throw GhosttyIntentError.appUnavailable } diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift new file mode 100644 index 000000000..35694081c --- /dev/null +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -0,0 +1,162 @@ +import AppKit +import Foundation + +/// Displays a permission request dialog with optional caching of user decisions +class PermissionRequest { + /// Shows a permission request dialog with customizable caching behavior + /// - Parameters: + /// - key: Unique identifier for storing/retrieving cached decisions in UserDefaults + /// - message: The message to display in the alert dialog + /// - allowText: Custom text for the allow button (defaults to "Allow") + /// - allowDuration: If provided, automatically cache "Allow" responses for this duration + /// - window: If provided, shows the alert as a sheet attached to this window + /// - completion: Called with the user's decision (true for allow, false for deny) + /// + /// Caching behavior: + /// - If user checks "Remember my decision for one day", both allow/deny are cached for 24 hours + /// - If allowDuration is provided and user selects allow (without checkbox), decision is cached for that duration + /// - Cached decisions are automatically returned without showing the dialog + @MainActor + static func show( + _ key: String, + message: String, + informative: String = "", + allowText: String = "Allow", + allowDuration: Duration? = nil, + window: NSWindow? = nil, + completion: @escaping (Bool) -> Void + ) { + // Check if we have a stored decision that hasn't expired + if let storedResult = getStoredResult(for: key) { + completion(storedResult) + return + } + + let alert = NSAlert() + alert.messageText = message + alert.informativeText = informative + alert.alertStyle = .informational + + // Add buttons (they appear in reverse order) + alert.addButton(withTitle: allowText) + alert.addButton(withTitle: "Don't Allow") + + // Create checkbox for remembering + let checkbox = NSButton( + checkboxWithTitle: "Remember my decision for one day", + target: nil, + action: nil) + checkbox.state = .off + + // Set checkbox as accessory view + alert.accessoryView = checkbox + + // Show the alert + if let window = window { + alert.beginSheetModal(for: window) { response in + handleResponse(response, rememberDecision: checkbox.state == .on, key: key, allowDuration: allowDuration, completion: completion) + } + } else { + let response = alert.runModal() + handleResponse(response, rememberDecision: checkbox.state == .on, key: key, allowDuration: allowDuration, completion: completion) + } + } + + /// Handles the alert response and processes caching logic + /// - Parameters: + /// - response: The alert response from the user + /// - rememberDecision: Whether the remember checkbox was checked + /// - key: The UserDefaults key for caching + /// - allowDuration: Optional duration for auto-caching allow responses + /// - completion: Completion handler to call with the result + private static func handleResponse( + _ response: NSApplication.ModalResponse, + rememberDecision: Bool, + key: String, + allowDuration: Duration?, + completion: @escaping (Bool) -> Void) { + + let result: Bool + switch response { + case .alertFirstButtonReturn: // Allow + result = true + case .alertSecondButtonReturn: // Don't Allow + result = false + default: + result = false + } + + // Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set + if rememberDecision { + storeResult(result, for: key, duration: .seconds(86400)) + } else if result, let allowDuration { + storeResult(result, for: key, duration: allowDuration) + } + + completion(result) + } + + /// Retrieves a cached permission decision if it hasn't expired + /// - Parameter key: The UserDefaults key to check + /// - Returns: The cached decision, or nil if no valid cached decision exists + private static func getStoredResult(for key: String) -> Bool? { + let userDefaults = UserDefaults.standard + guard let data = userDefaults.data(forKey: key), + let storedPermission = try? NSKeyedUnarchiver.unarchivedObject( + ofClass: StoredPermission.self, from: data) else { + return nil + } + + if Date() > storedPermission.expiry { + // Decision has expired, remove stored value + userDefaults.removeObject(forKey: key) + return nil + } + + return storedPermission.result + } + + /// Stores a permission decision in UserDefaults with an expiration date + /// - Parameters: + /// - result: The permission decision to store + /// - key: The UserDefaults key to store under + /// - duration: How long the decision should be cached + private static func storeResult(_ result: Bool, for key: String, duration: Duration) { + let expiryDate = Date().addingTimeInterval(duration.timeInterval) + let storedPermission = StoredPermission(result: result, expiry: expiryDate) + if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) { + let userDefaults = UserDefaults.standard + userDefaults.set(data, forKey: key) + } + } + + /// Internal class for storing permission decisions with expiration dates in UserDefaults + /// Conforms to NSSecureCoding for safe archiving/unarchiving + @objc(StoredPermission) + private class StoredPermission: NSObject, NSSecureCoding { + static var supportsSecureCoding: Bool = true + + let result: Bool + let expiry: Date + + init(result: Bool, expiry: Date) { + self.result = result + self.expiry = expiry + super.init() + } + + required init?(coder: NSCoder) { + self.result = coder.decodeBool(forKey: "result") + guard let expiry = coder.decodeObject(of: NSDate.self, forKey: "expiry") as? Date else { + return nil + } + self.expiry = expiry + super.init() + } + + func encode(with coder: NSCoder) { + coder.encode(result, forKey: "result") + coder.encode(expiry, forKey: "expiry") + } + } +} From b6559d08994ebb39ef66d210cd5461e03c594637 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Jun 2025 11:54:19 -0700 Subject: [PATCH 24/27] macos: add a macos-shortcut config --- .../App Intents/IntentPermission.swift | 21 ++++++++++++- macos/Sources/Ghostty/Ghostty.Config.swift | 17 ++++++++++ src/config/Config.zig | 31 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/App Intents/IntentPermission.swift b/macos/Sources/Features/App Intents/IntentPermission.swift index e02c4591d..78efb3d5d 100644 --- a/macos/Sources/Features/App Intents/IntentPermission.swift +++ b/macos/Sources/Features/App Intents/IntentPermission.swift @@ -1,5 +1,7 @@ +import AppKit + /// Requests permission for Shortcuts app to interact with Ghostty -/// +/// /// This function displays a permission dialog asking the user to allow Shortcuts /// to interact with Ghostty. The permission is automatically cached for 10 minutes /// if the user selects "Allow", meaning subsequent intent calls won't show the dialog @@ -25,6 +27,23 @@ func requestIntentPermission() async -> Bool { await withCheckedContinuation { continuation in Task { @MainActor in + if let delegate = NSApp.delegate as? AppDelegate { + switch (delegate.ghostty.config.macosShortcuts) { + case .allow: + continuation.resume(returning: true) + return + + case .deny: + continuation.resume(returning: false) + return + + case .ask: + // Continue with the permission dialog + break + } + } + + PermissionRequest.show( "org.mitchellh.ghostty.shortcutsPermission", message: "Allow Shortcuts to interact with Ghostty for the next 10 minutes?", diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index fcbea2a12..241c10632 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -558,6 +558,17 @@ extension Ghostty { _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } + + var macosShortcuts: MacShortcuts { + let defaultValue = MacShortcuts.ask + guard let config = self.config else { return defaultValue } + var v: UnsafePointer? = nil + let key = "macos-shortcuts" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard let ptr = v else { return defaultValue } + let str = String(cString: ptr) + return MacShortcuts(rawValue: str) ?? defaultValue + } } } @@ -584,6 +595,12 @@ extension Ghostty.Config { case always } + enum MacShortcuts: String { + case allow + case deny + case ask + } + enum ResizeOverlay : String { case always case never diff --git a/src/config/Config.zig b/src/config/Config.zig index e9370d9b3..aee670213 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2355,6 +2355,30 @@ keybind: Keybinds = .{}, /// @"macos-icon-screen-color": ?ColorList = null, +/// Whether macOS Shortcuts are allowed to control Ghostty. +/// +/// Ghostty exposes a number of actions that allow Shortcuts to +/// control and interact with Ghostty. This includes creating new +/// terminals, sending text to terminals, running commands, invoking +/// any keybind action, etc. +/// +/// This is a powerful feature but can be a security risk if a malicious +/// shortcut is able to be installed and executed. Therefore, this +/// configuration allows you to disable this feature. +/// +/// Valid values are: +/// +/// * `ask` - Ask the user whether for permission. Ghostty will by default +/// cache the user's choice for 10 minutes since we can't determine +/// when a single workflow begins or ends. The user also has an option +/// in the GUI to allow for the remainder of the day. +/// +/// * `allow` - Allow Shortcuts to control Ghostty without asking. +/// +/// * `deny` - Deny Shortcuts from controlling Ghostty. +/// +@"macos-shortcuts": MacShortcuts = .ask, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface @@ -5961,6 +5985,13 @@ pub const MacAppIconFrame = enum { chrome, }; +/// See macos-shortcuts +pub const MacShortcuts = enum { + allow, + deny, + ask, +}; + /// See gtk-single-instance pub const GtkSingleInstance = enum { desktop, From e4c13cdba87761dfd8feb77b7e57231f8032415f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Jun 2025 12:09:03 -0700 Subject: [PATCH 25/27] macos: Optional/Array extensions need to build for iOS too --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a203ad682..416d8b106 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; }; A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; + A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; }; + A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; }; A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; @@ -892,6 +894,7 @@ buildActionMask = 2147483647; files = ( A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */, + A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */, A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, @@ -901,6 +904,7 @@ A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */, A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */, A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */, + A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */, C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 020976bf8859324f2cd653988988b08b60ae300c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Jun 2025 06:42:31 -0700 Subject: [PATCH 26/27] macos: address some feedback --- .../App Intents/Entities/TerminalEntity.swift | 6 +++--- .../Features/App Intents/GhosttyIntentError.swift | 6 +++--- .../Sources/Helpers/Extensions/Array+Extension.swift | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift index 1fb69f1f8..e29fbba3f 100644 --- a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift +++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift @@ -130,10 +130,10 @@ struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery { let controllers = NSApp.windows.compactMap { $0.windowController as? BaseTerminalController } - + // Get all our surfaces - return controllers.reduce([]) { result, c in - result + (c.surfaceTree.root?.leaves() ?? []) + return controllers.flatMap { + $0.surfaceTree.root?.leaves() ?? [] } } } diff --git a/macos/Sources/Features/App Intents/GhosttyIntentError.swift b/macos/Sources/Features/App Intents/GhosttyIntentError.swift index 635250f72..c52b7a52e 100644 --- a/macos/Sources/Features/App Intents/GhosttyIntentError.swift +++ b/macos/Sources/Features/App Intents/GhosttyIntentError.swift @@ -5,9 +5,9 @@ enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible { var localizedStringResource: LocalizedStringResource { switch self { - case .appUnavailable: return "The Ghostty app isn't properly initialized." - case .surfaceNotFound: return "The terminal no longer exists." - case .permissionDenied: return "Ghostty doesn't allow Shortcuts." + case .appUnavailable: "The Ghostty app isn't properly initialized." + case .surfaceNotFound: "The terminal no longer exists." + case .permissionDenied: "Ghostty doesn't allow Shortcuts." } } } diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift index fac340472..4e8e39918 100644 --- a/macos/Sources/Helpers/Extensions/Array+Extension.swift +++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift @@ -34,12 +34,12 @@ extension Array where Element == String { func helper(index: Int, accumulated: [UnsafePointer?], body: ([UnsafePointer?]) throws -> T) rethrows -> T { if index == count { return try body(accumulated) - } else { - return try self[index].withCString { cStr in - var newAccumulated = accumulated - newAccumulated.append(cStr) - return try helper(index: index + 1, accumulated: newAccumulated, body: body) - } + } + + return try self[index].withCString { cStr in + var newAccumulated = accumulated + newAccumulated.append(cStr) + return try helper(index: index + 1, accumulated: newAccumulated, body: body) } } From 296f340ff425c9986fedc2dbb4b5faa496dc63e3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Jun 2025 06:46:33 -0700 Subject: [PATCH 27/27] macos: the approval dialog is now forever --- .../App Intents/IntentPermission.swift | 5 +- macos/Sources/Helpers/PermissionRequest.swift | 87 +++++++++++++++---- src/config/Config.zig | 7 +- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/macos/Sources/Features/App Intents/IntentPermission.swift b/macos/Sources/Features/App Intents/IntentPermission.swift index 78efb3d5d..2ec4f2bd9 100644 --- a/macos/Sources/Features/App Intents/IntentPermission.swift +++ b/macos/Sources/Features/App Intents/IntentPermission.swift @@ -46,8 +46,9 @@ func requestIntentPermission() async -> Bool { PermissionRequest.show( "org.mitchellh.ghostty.shortcutsPermission", - message: "Allow Shortcuts to interact with Ghostty for the next 10 minutes?", - allowDuration: .seconds(600), + message: "Allow Shortcuts to interact with Ghostty?", + allowDuration: .forever, + rememberDuration: nil, ) { response in continuation.resume(returning: response) } diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift index 35694081c..9c16c7163 100644 --- a/macos/Sources/Helpers/PermissionRequest.swift +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -3,17 +3,25 @@ import Foundation /// Displays a permission request dialog with optional caching of user decisions class PermissionRequest { + /// Specifies how long a permission decision should be cached + enum AllowDuration { + case once + case forever + case duration(Duration) + } + /// Shows a permission request dialog with customizable caching behavior /// - Parameters: /// - key: Unique identifier for storing/retrieving cached decisions in UserDefaults /// - message: The message to display in the alert dialog /// - allowText: Custom text for the allow button (defaults to "Allow") /// - allowDuration: If provided, automatically cache "Allow" responses for this duration + /// - rememberDuration: If provided, shows a checkbox to remember the decision for this duration /// - window: If provided, shows the alert as a sheet attached to this window /// - completion: Called with the user's decision (true for allow, false for deny) /// /// Caching behavior: - /// - If user checks "Remember my decision for one day", both allow/deny are cached for 24 hours + /// - If rememberDuration is provided and user checks "Remember my decision", both allow/deny are cached for that duration /// - If allowDuration is provided and user selects allow (without checkbox), decision is cached for that duration /// - Cached decisions are automatically returned without showing the dialog @MainActor @@ -22,7 +30,8 @@ class PermissionRequest { message: String, informative: String = "", allowText: String = "Allow", - allowDuration: Duration? = nil, + allowDuration: AllowDuration = .once, + rememberDuration: Duration? = .seconds(86400), window: NSWindow? = nil, completion: @escaping (Bool) -> Void ) { @@ -41,24 +50,28 @@ class PermissionRequest { alert.addButton(withTitle: allowText) alert.addButton(withTitle: "Don't Allow") - // Create checkbox for remembering - let checkbox = NSButton( - checkboxWithTitle: "Remember my decision for one day", - target: nil, - action: nil) - checkbox.state = .off - - // Set checkbox as accessory view - alert.accessoryView = checkbox + // Create checkbox for remembering if duration is provided + var checkbox: NSButton? + if let rememberDuration = rememberDuration { + let checkboxTitle = formatRememberText(for: rememberDuration) + checkbox = NSButton( + checkboxWithTitle: checkboxTitle, + target: nil, + action: nil) + checkbox!.state = .off + + // Set checkbox as accessory view + alert.accessoryView = checkbox + } // Show the alert if let window = window { alert.beginSheetModal(for: window) { response in - handleResponse(response, rememberDecision: checkbox.state == .on, key: key, allowDuration: allowDuration, completion: completion) + handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion) } } else { let response = alert.runModal() - handleResponse(response, rememberDecision: checkbox.state == .on, key: key, allowDuration: allowDuration, completion: completion) + handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion) } } @@ -68,12 +81,14 @@ class PermissionRequest { /// - rememberDecision: Whether the remember checkbox was checked /// - key: The UserDefaults key for caching /// - allowDuration: Optional duration for auto-caching allow responses + /// - rememberDuration: Optional duration for the remember checkbox /// - completion: Completion handler to call with the result private static func handleResponse( _ response: NSApplication.ModalResponse, rememberDecision: Bool, key: String, - allowDuration: Duration?, + allowDuration: AllowDuration, + rememberDuration: Duration?, completion: @escaping (Bool) -> Void) { let result: Bool @@ -87,10 +102,21 @@ class PermissionRequest { } // Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set - if rememberDecision { - storeResult(result, for: key, duration: .seconds(86400)) - } else if result, let allowDuration { - storeResult(result, for: key, duration: allowDuration) + if rememberDecision, let rememberDuration = rememberDuration { + storeResult(result, for: key, duration: rememberDuration) + } else if result { + switch allowDuration { + case .once: + // Don't store anything for once + break + case .forever: + // Store for a very long time (100 years). When the bug comes in that + // 100 years has passed and their forever permission expired I'll be + // dead so it won't be my problem. + storeResult(result, for: key, duration: .seconds(3153600000)) + case .duration(let duration): + storeResult(result, for: key, duration: duration) + } } completion(result) @@ -130,6 +156,31 @@ class PermissionRequest { } } + /// Formats the remember checkbox text based on the duration + /// - Parameter duration: The duration to format + /// - Returns: A human-readable string for the checkbox + private static func formatRememberText(for duration: Duration) -> String { + let seconds = duration.timeInterval + + // Warning: this probably isn't localization friendly at all so we're + // going to have to redo this for that. + switch seconds { + case 0..<60: + return "Remember my decision for \(Int(seconds)) seconds" + case 60..<3600: + let minutes = Int(seconds / 60) + return "Remember my decision for \(minutes) minute\(minutes == 1 ? "" : "s")" + case 3600..<86400: + let hours = Int(seconds / 3600) + return "Remember my decision for \(hours) hour\(hours == 1 ? "" : "s")" + case 86400: + return "Remember my decision for one day" + default: + let days = Int(seconds / 86400) + return "Remember my decision for \(days) day\(days == 1 ? "" : "s")" + } + } + /// Internal class for storing permission decisions with expiration dates in UserDefaults /// Conforms to NSSecureCoding for safe archiving/unarchiving @objc(StoredPermission) diff --git a/src/config/Config.zig b/src/config/Config.zig index aee670213..aabf4f6ba 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2368,10 +2368,9 @@ keybind: Keybinds = .{}, /// /// Valid values are: /// -/// * `ask` - Ask the user whether for permission. Ghostty will by default -/// cache the user's choice for 10 minutes since we can't determine -/// when a single workflow begins or ends. The user also has an option -/// in the GUI to allow for the remainder of the day. +/// * `ask` - Ask the user whether for permission. Ghostty will remember +/// this choice and never ask again. This is similar to other macOS +/// permissions such as microphone access, camera access, etc. /// /// * `allow` - Allow Shortcuts to control Ghostty without asking. ///