macOS: App Intents (#7634)

This PR integrates Ghostty on macOS with the [App
Intents](https://developer.apple.com/documentation/appintents) system.
The focus of this initial work was on enabling [Apple
Shortcuts](https://support.apple.com/guide/shortcuts/welcome/ios) on
macOS, but App Intents are the same underlying system that powers a
number of other Apple features such as Spotlight, Siri, Widgets, and
more. We don't do much with these latter ones yet, though.

Additionally, this PR begins to refactor and untangle some of our
libghostty API calls from macOS views. Presently, macOS views and view
controllers directly call into the libghostty API and own libghostty
data models. This tight coupling is kind of nasty because it tends to
also couple libghostty API calls to the main GUI thread when they don't
really have to be (they just have to not be concurrently accessed). This
becomes an issue because App Intents run on background threads. This PR
starts to extract out some of this business logic into standalone
classes, but we still force all execution onto the main thread during
the transition.

**Version requirement:** Most of the shortcuts will work on macOS 13,
but there are some that require macOS 14, and some functionality will
require macOS 26. We gracefully degrade in all scenarios (the
capabilities that are unavailable just don't show up on older systems).

> [!IMPORTANT]
>
> This bumps our build requirements on macOS to Xcode 26 and the macOS
26 SDK. **You can still build on macOS 15,** but you must be using the
Xcode 26 beta. This includes a README update about that.

## Why?

Apple Shortcuts is an extremely powerful scripting tool on Apple
platforms. It comes with a number of built-in capabilities such as
moving windows, taking screenshots, fetching secrets, and more. By
integrating with Apple Shortcuts, it allows Ghostty to become scriptable
to a certain extent while also being able to take advantage of this
large ecosystem.

It is a huge downside that Shortcuts is Apple-only and I still would
like to make Ghostty scriptable to some extent on Linux and other
platforms as well. This work doesn't preclude that goal, but gives us an
answer to a large subset of users (macOS users), which is great.

Beyond this, no terminals integrate with Apple Shortcuts except the
built-in Terminal. And even then, the built-in Terminal only exposes two
actions (run script and run script over SSH). I think there's a lot that
can be done by exposing more functionality and I'm excited to see what
people do with this.

Finally, I think Shortcuts is possibly a way we can do some GUI testing
on macOS. That remains to be explored but it seems promising.

## Capability

The initial set of Shortcut actions is shown in the screenshot below:

![CleanShot 2025-06-20 at 12 19
47@2x](https://github.com/user-attachments/assets/07ac3901-8871-4ee5-a7da-663e0e2a90db)

These can be combined with the built-in shortcuts to do some pretty
interesting things.

### Future

There are more capabilities I'd like to expose, but they require
changing core parts of Ghostty that I didn't want to mix into this PR.

## Security

Scripting Ghostty can be considered a security risk, since it allows
arbitrary command execution, reading terminal output, etc. Therefore,
Ghostty will ask for permission prior to allowing any Shortcut to remote
control it:

<img width="859" alt="image"
src="https://github.com/user-attachments/assets/62344248-9c2c-402d-80f6-3fe3910d23fd"
/>

This can be directly overridden using the new `macos-shortcuts`
configuration, which defaults to `ask` but can also be set to `deny` or
`allow` with self-explanatory behaviors.
This commit is contained in:
Mitchell Hashimoto
2025-06-21 07:07:10 -07:00
committed by GitHub
37 changed files with 3150 additions and 388 deletions

View File

@ -18,7 +18,6 @@ jobs:
- build-nix - build-nix
- build-snap - build-snap
- build-macos - build-macos
- build-macos-sequoia-stable
- build-macos-tahoe - build-macos-tahoe
- build-macos-matrix - build-macos-matrix
- build-windows - build-windows
@ -310,46 +309,6 @@ jobs:
cd macos cd macos
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" 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: build-macos-tahoe:
runs-on: namespace-profile-ghostty-macos-tahoe runs-on: namespace-profile-ghostty-macos-tahoe
needs: test needs: test

View File

@ -224,6 +224,28 @@ macOS users don't require any additional dependencies.
> source tarballs, see the > source tarballs, see the
> [website](http://ghostty.org/docs/install/build). > [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 ### Linting
#### Prettier #### Prettier

View File

@ -385,6 +385,11 @@ typedef struct {
bool rectangle; bool rectangle;
} ghostty_selection_s; } ghostty_selection_s;
typedef struct {
const char* key;
const char* value;
} ghostty_env_var_s;
typedef struct { typedef struct {
void* nsview; void* nsview;
} ghostty_platform_macos_s; } ghostty_platform_macos_s;
@ -406,6 +411,8 @@ typedef struct {
float font_size; float font_size;
const char* working_directory; const char* working_directory;
const char* command; const char* command;
ghostty_env_var_s* env_vars;
size_t env_var_count;
} ghostty_surface_config_s; } ghostty_surface_config_s;
typedef struct { 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_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_free(ghostty_surface_t);
void* ghostty_surface_userdata(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t);
ghostty_app_t ghostty_surface_app(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t);

View File

@ -13,6 +13,11 @@
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; }; 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 */; };
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 */; }; 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 */; }; 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 */; }; A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
@ -53,7 +58,8 @@
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; }; A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; }; A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; };
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.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 */; }; A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; };
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; }; A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; };
A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; }; A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; };
@ -120,6 +126,17 @@
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.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 */; };
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 */; };
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 */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.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 */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
@ -139,6 +156,11 @@
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; }; A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = "<group>"; };
A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; }; A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; }; A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; }; A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
@ -171,7 +193,6 @@
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; }; A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; }; A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; }; A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = "<group>"; }; A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = "<group>"; };
@ -240,6 +261,17 @@
A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; }; A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; };
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = "<group>"; }; A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = "<group>"; };
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = "<group>"; };
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = "<group>"; };
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = "<group>"; };
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = "<group>"; };
A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = "<group>"; };
A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = "<group>"; };
A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = "<group>"; };
A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = "<group>"; };
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
@ -299,6 +331,7 @@
A56D58872ACDE6BE00508D2C /* Services */, A56D58872ACDE6BE00508D2C /* Services */,
A59630982AEE1C4400D64628 /* Terminal */, A59630982AEE1C4400D64628 /* Terminal */,
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
A5E4082C2E0237270035FEAC /* App Intents */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
A57D79252C9C8782001D522E /* Secure Input */, A57D79252C9C8782001D522E /* Secure Input */,
A58636622DEF955100E04A10 /* Splits */, A58636622DEF955100E04A10 /* Splits */,
@ -328,6 +361,7 @@
A59630962AEE163600D64628 /* HostingWindow.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A51194162E05D95E007258CC /* PermissionRequest.swift */,
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */, A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
@ -431,12 +465,14 @@
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */,
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */, A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */,
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */, A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */, A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */,
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */, A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */, A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
); );
@ -479,6 +515,7 @@
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
A51194122E05D003007258CC /* Optional+Extension.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */, A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */, A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
@ -598,6 +635,32 @@
path = ClipboardConfirmation; path = ClipboardConfirmation;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A5E4082C2E0237270035FEAC /* App Intents */ = {
isa = PBXGroup;
children = (
A5E408412E0453370035FEAC /* Entities */,
A511940E2E050590007258CC /* CloseTerminalIntent.swift */,
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */,
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */,
A51194102E05A480007258CC /* QuickTerminalIntent.swift */,
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */,
A5E408462E0485270035FEAC /* InputIntent.swift */,
A5E408442E0483F80035FEAC /* KeybindIntent.swift */,
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */,
A51194182E05DFBB007258CC /* IntentPermission.swift */,
);
path = "App Intents";
sourceTree = "<group>";
};
A5E408412E0453370035FEAC /* Entities */ = {
isa = PBXGroup;
children = (
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */,
A5E4083F2E04532A0035FEAC /* CommandEntity.swift */,
);
path = Entities;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -724,6 +787,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */, A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
@ -733,17 +797,22 @@
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */,
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.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 */, A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */, A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */, A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
@ -752,6 +821,7 @@
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */, A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */, A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */,
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */,
@ -759,6 +829,8 @@
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.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 */, A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
@ -772,29 +844,35 @@
A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */, A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */,
A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */, A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */,
A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */, A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */, 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 */, A5FEB3002ABB69450068369E /* main.swift in Sources */,
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */, A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */,
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */, A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */, A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */, A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */, A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */, A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */, A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */, A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */, A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */,
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */, A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */, A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */,
@ -816,6 +894,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */, A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */,
A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */,
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
@ -825,6 +904,7 @@
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */, A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */, A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */, A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */,
A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */,
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */, C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -92,7 +92,10 @@ class AppDelegate: NSObject,
lazy var undoManager = ExpiringUndoManager() lazy var undoManager = ExpiringUndoManager()
/// Our quick terminal. This starts out uninitialized and only initializes if used. /// 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 /// Manages updates
let updaterController: SPUStandardUpdaterController 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 // 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 // here because I don't want to remove it in a patch release cycle but we should
// target removing it soon. // target removing it soon.
if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) { if (windows.allSatisfy { !$0.isVisible }) {
return .terminateNow return .terminateNow
} }
@ -919,14 +922,6 @@ class AppDelegate: NSObject,
} }
@IBAction func toggleQuickTerminal(_ sender: Any) { @IBAction func toggleQuickTerminal(_ sender: Any) {
if quickController == nil {
quickController = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition
)
}
guard let quickController = self.quickController else { return }
quickController.toggle() quickController.toggle()
} }

View File

@ -0,0 +1,35 @@
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
@available(macOS 26.0, *)
static var supportedModes: IntentModes = .background
@MainActor
func perform() async throws -> some IntentResult {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surfaceView = terminal.surfaceView else {
throw GhosttyIntentError.surfaceNotFound
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
return .result()
}
controller.closeSurface(surfaceView, withConfirmation: false)
return .result()
}
}

View File

@ -0,0 +1,38 @@
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<Bool> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
let performed = surface.perform(action: command.action)
return .result(value: performed)
}
}

View File

@ -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<CommandPaletteIntent>(\.$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) }
}
}

View File

@ -0,0 +1,139 @@
import AppKit
import AppIntents
import SwiftUI
struct TerminalEntity: AppEntity {
let id: UUID
@Property(title: "Title")
var title: String
@Property(title: "Working Directory")
var workingDirectory: String?
@Property(title: "Kind")
var kind: Kind
@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 {
TypeDisplayRepresentation(name: "Terminal")
}
@MainActor
var displayRepresentation: DisplayRepresentation {
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 }
}
@MainActor
var surfaceModel: Ghostty.Surface? {
surfaceView?.surfaceModel
}
static var defaultQuery = TerminalQuery()
init(_ view: Ghostty.SurfaceView) {
self.id = view.uuid
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")
]
}
}
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
var all: [Ghostty.SurfaceView] {
// 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
}
// Get all our surfaces
return controllers.flatMap {
$0.surfaceTree.root?.leaves() ?? []
}
}
}

View File

@ -0,0 +1,69 @@
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<String?> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
switch detail {
case .title: return .result(value: terminal.title)
case .workingDirectory: return .result(value: terminal.workingDirectory)
case .allContents:
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
return .result(value: view.cachedScreenContents.get())
case .selectedText:
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
return .result(value: view.accessibilitySelectedText())
case .visibleText:
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
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"),
]
}

View File

@ -0,0 +1,13 @@
enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible {
case appUnavailable
case surfaceNotFound
case permissionDenied
var localizedStringResource: LocalizedStringResource {
switch self {
case .appUnavailable: "The Ghostty app isn't properly initialized."
case .surfaceNotFound: "The terminal no longer exists."
case .permissionDenied: "Ghostty doesn't allow Shortcuts."
}
}
}

View File

@ -0,0 +1,317 @@
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 await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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: "Key",
description: "The key to send to the terminal.",
default: .enter
)
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",
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 await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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 keyEvent = Ghostty.Input.KeyEvent(
key: key,
action: action,
mods: ghosttyMods
)
surface.sendKeyEvent(keyEvent)
return .result()
}
}
// 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 await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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()
}
}
/// 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 await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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 await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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 {
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
}
}
}

View File

@ -0,0 +1,57 @@
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
/// 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
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?",
allowDuration: .forever,
rememberDuration: nil,
) { response in
continuation.resume(returning: response)
}
}
}
}

View File

@ -0,0 +1,35 @@
import AppKit
import AppIntents
struct KeybindIntent: AppIntent {
static var title: LocalizedStringResource = "Invoke a Keybind Action"
@Parameter(
title: "Terminal",
description: "The terminal to invoke the action on."
)
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<Bool> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
guard let surface = terminal.surfaceModel else {
throw GhosttyIntentError.surfaceNotFound
}
let performed = surface.perform(action: action)
return .result(value: performed)
}
}

View File

@ -0,0 +1,163 @@
import AppKit
import AppIntents
import GhosttyKit
/// 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: "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.",
supportedContentTypes: [.folder]
)
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."
)
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
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
guard await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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 {
let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent()
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[..<separatorIndex])
let value = String(envVar[envVar.index(after: separatorIndex)...])
config.environmentVariables[key] = value
}
}
// 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:
let newController = TerminalController.newWindow(
ghostty,
withBaseConfig: config,
withParent: parent?.window)
if let view = newController.surfaceTree.root?.leftmostLeaf() {
return .result(value: TerminalEntity(view))
}
case .tab:
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(value: .none)
}
}
// MARK: NewTerminalLocation
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<Ghostty.SurfaceView>.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 {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Location")
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"),
]
}

View File

@ -0,0 +1,32 @@
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 await requestIntentPermission() else {
throw GhosttyIntentError.permissionDenied
}
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)
}
}

View File

@ -17,34 +17,20 @@ struct TerminalCommandPaletteView: View {
// The commands available to the command palette. // The commands available to the command palette.
private var commandOptions: [CommandOption] { private var commandOptions: [CommandOption] {
guard let surface = surfaceView.surface else { return [] } guard let surface = surfaceView.surfaceModel else { return [] }
do {
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil return try surface.commands().map { c in
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( return CommandOption(
title: String(cString: c.title), title: c.title,
description: String(cString: c.description), description: c.description,
symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
) { ) {
onAction(action) onAction(c.action)
} }
} }
} catch {
return []
}
} }
var body: some View { var body: some View {

View File

@ -42,7 +42,11 @@ class QuickTerminalController: BaseTerminalController {
) { ) {
self.position = position self.position = position
self.derivedConfig = DerivedConfig(ghostty.config) 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 // Setup our notifications for behaviors
let center = NotificationCenter.default let center = NotificationCenter.default
@ -218,19 +222,19 @@ class QuickTerminalController: BaseTerminalController {
} }
} }
override func closeSurfaceNode( override func closeSurface(
_ node: SplitTree<Ghostty.SurfaceView>.Node, _ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true withConfirmation: Bool = true
) { ) {
// If this isn't the root then we're dealing with a split closure. // If this isn't the root then we're dealing with a split closure.
if surfaceTree.root != node { if surfaceTree.root != node {
super.closeSurfaceNode(node, withConfirmation: withConfirmation) super.closeSurface(node, withConfirmation: withConfirmation)
return return
} }
// If this isn't a final leaf then we're dealing with a split closure // If this isn't a final leaf then we're dealing with a split closure
guard case .leaf(let surface) = node else { guard case .leaf(let surface) = node else {
super.closeSurfaceNode(node, withConfirmation: withConfirmation) super.closeSurface(node, withConfirmation: withConfirmation)
return return
} }

View File

@ -193,6 +193,46 @@ class BaseTerminalController: NSWindowController,
} }
} }
// MARK: Methods
/// Create a new split.
@discardableResult
func newSplit(
at oldView: Ghostty.SurfaceView,
direction: SplitTree<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>
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. /// Called when the surfaceTree variable changed.
/// ///
/// Subclasses should call super first. /// Subclasses should call super first.
@ -260,6 +300,46 @@ class BaseTerminalController: NSWindowController,
self.alert = alert 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<Ghostty.SurfaceView>.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 // MARK: Split Tree Management
/// Find the next surface to focus when a node is being closed. /// Find the next surface to focus when a node is being closed.
@ -420,42 +500,11 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttyDidCloseSurface(_ notification: Notification) { @objc private func ghosttyDidCloseSurface(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let node = surfaceTree.root?.node(view: target) else { return } guard let node = surfaceTree.root?.node(view: target) else { return }
closeSurfaceNode( closeSurface(
node, node,
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) 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<Ghostty.SurfaceView>.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) { @objc private func ghosttyDidNewSplit(_ notification: Notification) {
// The target must be within our tree // The target must be within our tree
guard let oldView = notification.object as? Ghostty.SurfaceView else { return } guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
@ -477,30 +526,7 @@ class BaseTerminalController: NSWindowController,
default: return default: return
} }
// Create a new surface view newSplit(at: oldView, direction: splitDirection, baseConfig: config)
guard let ghostty_app = ghostty.app else { return }
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
// Do the split
let newTree: SplitTree<Ghostty.SurfaceView>
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")
} }
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {

View File

@ -169,7 +169,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
private static var lastCascadePoint = NSPoint(x: 0, y: 0) private static var lastCascadePoint = NSPoint(x: 0, y: 0)
// The preferred parent terminal controller. // The preferred parent terminal controller.
private static var preferredParent: TerminalController? { static var preferredParent: TerminalController? {
all.first { all.first {
$0.window?.isMainWindow ?? false $0.window?.isMainWindow ?? false
} ?? all.last } ?? all.last
@ -519,13 +519,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
} }
/// This is called anytime a node in the surface tree is being removed. /// This is called anytime a node in the surface tree is being removed.
override func closeSurfaceNode( override func closeSurface(
_ node: SplitTree<Ghostty.SurfaceView>.Node, _ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true withConfirmation: Bool = true
) { ) {
// If this isn't the root then we're dealing with a split closure. // If this isn't the root then we're dealing with a split closure.
if surfaceTree.root != node { if surfaceTree.root != node {
super.closeSurfaceNode(node, withConfirmation: withConfirmation) super.closeSurface(node, withConfirmation: withConfirmation)
return return
} }

View File

@ -1,3 +0,0 @@
enum AppError: Error {
case surfaceCreateError
}

View File

@ -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
}
}
}

View File

@ -558,6 +558,17 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count)) _ = ghostty_config_get(config, &v, key, UInt(key.count))
return v return v
} }
var macosShortcuts: MacShortcuts {
let defaultValue = MacShortcuts.ask
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>? = 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 case always
} }
enum MacShortcuts: String {
case allow
case deny
case ask
}
enum ResizeOverlay : String { enum ResizeOverlay : String {
case always case always
case never case never

View File

@ -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"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
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)
}
}
/// 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))
}
}
/// 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)
}
}
/// 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)
}
/// 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`
/// 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<ghostty_command_s>? = 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 }
}
}
}

View File

@ -337,9 +337,9 @@ extension Ghostty {
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let inspector = self.inspector else { return } guard let inspector = self.inspector else { return }
guard let key = Ghostty.keycodeToKey[event.keyCode] else { return } guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_key(inspector, action, key, mods) ghostty_inspector_key(inspector, action, key.cKey, mods)
} }
// MARK: NSTextInputClient // MARK: NSTextInputClient

View File

@ -19,6 +19,15 @@ struct Ghostty {
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" 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 // MARK: Build Info
extension Ghostty { extension Ghostty {

View File

@ -79,7 +79,7 @@ extension Ghostty {
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
#endif #endif
Surface(view: surfaceView, size: geo.size) SurfaceRepresentable(view: surfaceView, size: geo.size)
.focused($surfaceFocus) .focused($surfaceFocus)
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd) .focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
.focusedValue(\.ghosttySurfaceView, surfaceView) .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 /// 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 /// 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. /// 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. /// The view to render for the terminal surface.
let view: SurfaceView let view: SurfaceView
@ -419,17 +419,35 @@ extension Ghostty {
/// Explicit command to set /// Explicit command to set
var command: String? = nil var command: String? = nil
/// Environment variables to set for the terminal
var environmentVariables: [String: String] = [:]
init() {} init() {}
init(from config: ghostty_surface_config_s) { init(from config: ghostty_surface_config_s) {
self.fontSize = config.font_size self.fontSize = config.font_size
self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8) if let workingDirectory = config.working_directory {
self.command = String.init(cString: config.command, encoding: .utf8) self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8)
}
if let command = config.command {
self.command = String.init(cString: command, encoding: .utf8)
} }
/// Returns the ghostty configuration for this surface configuration struct. The memory // Convert the C env vars to Swift dictionary
/// in the returned struct is only valid as long as this struct is retained. if config.env_var_count > 0, let envVars = config.env_vars {
func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s { for i in 0..<config.env_var_count {
let envVar = envVars[i]
if let key = String(cString: envVar.key, encoding: .utf8),
let value = String(cString: envVar.value, encoding: .utf8) {
self.environmentVariables[key] = value
}
}
}
}
/// Provides a C-compatible ghostty configuration within a closure. The configuration
/// and all its string pointers are only valid within the closure.
func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
var config = ghostty_surface_config_new() var config = ghostty_surface_config_new()
config.userdata = Unmanaged.passUnretained(view).toOpaque() config.userdata = Unmanaged.passUnretained(view).toOpaque()
#if os(macOS) #if os(macOS)
@ -438,7 +456,6 @@ extension Ghostty {
nsview: Unmanaged.passUnretained(view).toOpaque() nsview: Unmanaged.passUnretained(view).toOpaque()
)) ))
config.scale_factor = NSScreen.main!.backingScaleFactor config.scale_factor = NSScreen.main!.backingScaleFactor
#elseif os(iOS) #elseif os(iOS)
config.platform_tag = GHOSTTY_PLATFORM_IOS config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
@ -453,15 +470,42 @@ extension Ghostty {
#error("unsupported target") #error("unsupported target")
#endif #endif
if let fontSize = fontSize { config.font_size = fontSize } // Zero is our default value that means to inherit the font size.
if let workingDirectory = workingDirectory { config.font_size = fontSize ?? 0
config.working_directory = (workingDirectory as NSString).utf8String
} // Use withCString to ensure strings remain valid for the duration of the closure
if let command = command { return try workingDirectory.withCString { cWorkingDir in
config.command = (command as NSString).utf8String 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<ghostty_env_var_s>()
envVars.reserveCapacity(environmentVariables.count)
for i in 0..<environmentVariables.count {
envVars.append(ghostty_env_var_s(
key: keyCStrings[i],
value: valueCStrings[i]
))
} }
return config return try envVars.withUnsafeMutableBufferPointer { buffer in
config.env_vars = buffer.baseAddress
config.env_var_count = environmentVariables.count
return try body(&config)
}
}
}
}
}
} }
} }

View File

@ -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 // Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = [] var notificationIdentifiers: Set<String> = []
private(set) var surface: ghostty_surface_t?
private var markedText: NSMutableAttributedString private var markedText: NSMutableAttributedString
private(set) var focused: Bool = true private(set) var focused: Bool = true
private var prevPressureStage: Int = 0 private var prevPressureStage: Int = 0
@ -139,7 +149,8 @@ extension Ghostty {
private var titleFromTerminal: String? private var titleFromTerminal: String?
// The cached contents of the screen. // The cached contents of the screen.
private var cachedScreenContents: CachedValue<String> private(set) var cachedScreenContents: CachedValue<String>
private(set) var cachedVisibleContents: CachedValue<String>
/// Event monitor (see individual events for why) /// Event monitor (see individual events for why)
private var eventMonitor: Any? = nil private var eventMonitor: Any? = nil
@ -166,6 +177,7 @@ extension Ghostty {
// it back up later so we can reference `self`. This is a hack we should // it back up later so we can reference `self`. This is a hack we should
// fix at some point. // fix at some point.
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" } self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
self.cachedVisibleContents = self.cachedScreenContents
// Initialize with some default frame size. The important thing is that this // 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 // is non-zero so that our layer bounds are non-zero so that our renderer
@ -193,6 +205,26 @@ extension Ghostty {
defer { ghostty_surface_free_text(surface, &text) } defer { ghostty_surface_free_text(surface, &text) }
return String(cString: text.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 // 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 titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
@ -258,12 +290,14 @@ extension Ghostty {
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { ghostty_surface_new(app, &surface_cfg_c)
self.error = AppError.surfaceCreateError }
guard let surface = surface else {
self.error = Ghostty.Error.apiFailed
return return
} }
self.surface = surface; self.surfaceModel = Ghostty.Surface(cSurface: surface)
// Setup our tracking area so we get mouse moved events // Setup our tracking area so we get mouse moved events
updateTrackingAreas() updateTrackingAreas()
@ -318,11 +352,6 @@ extension Ghostty {
// Remove any notifications associated with this surface // Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers) let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
// Free our core surface resources
if let surface = self.surface {
ghostty_surface_free(surface)
}
} }
func focusDidChange(_ focused: Bool) { func focusDidChange(_ focused: Bool) {
@ -781,19 +810,23 @@ extension Ghostty {
override func mouseEntered(with event: NSEvent) { override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event) 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 // On mouse enter we need to reset our cursor position. This is
// super important because we set it to -1/-1 on mouseExit and // super important because we set it to -1/-1 on mouseExit and
// lots of mouse logic (i.e. whether to send mouse reports) depend // lots of mouse logic (i.e. whether to send mouse reports) depend
// on the position being in the viewport if it is. // on the position being in the viewport if it is.
let pos = self.convert(event.locationInWindow, from: nil) let pos = self.convert(event.locationInWindow, from: nil)
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mouseEvent = Ghostty.Input.MousePosEvent(
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) x: pos.x,
y: frame.height - pos.y,
mods: .init(nsFlags: event.modifierFlags)
)
surfaceModel.sendMousePos(mouseEvent)
} }
override func mouseExited(with event: NSEvent) { 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 // 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 // this because we get mouse drag events even if we've already
@ -803,17 +836,25 @@ extension Ghostty {
} }
// Negative values indicate cursor has left the viewport // Negative values indicate cursor has left the viewport
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mouseEvent = Ghostty.Input.MousePosEvent(
ghostty_surface_mouse_pos(surface, -1, -1, mods) x: -1,
y: -1,
mods: .init(nsFlags: event.modifierFlags)
)
surfaceModel.sendMousePos(mouseEvent)
} }
override func mouseMoved(with event: NSEvent) { 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. // Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil) let pos = self.convert(event.locationInWindow, from: nil)
let mods = Ghostty.ghosttyMods(event.modifierFlags) let mouseEvent = Ghostty.Input.MousePosEvent(
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) x: pos.x,
y: frame.height - pos.y,
mods: .init(nsFlags: event.modifierFlags)
)
surfaceModel.sendMousePos(mouseEvent)
// Handle focus-follows-mouse // Handle focus-follows-mouse
if let window, if let window,
@ -839,16 +880,13 @@ extension Ghostty {
} }
override func scrollWheel(with event: NSEvent) { override func scrollWheel(with event: NSEvent) {
guard let surface = self.surface else { return } guard let surfaceModel else { return }
// Builds up the "input.ScrollMods" bitmask
var mods: Int32 = 0
var x = event.scrollingDeltaX var x = event.scrollingDeltaX
var y = event.scrollingDeltaY var y = event.scrollingDeltaY
if event.hasPreciseScrollingDeltas { let precision = event.hasPreciseScrollingDeltas
mods = 1
if precision {
// We do a 2x speed multiplier. This is subjective, it "feels" better to me. // We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2; x *= 2;
y *= 2; y *= 2;
@ -856,29 +894,12 @@ extension Ghostty {
// TODO(mitchellh): do we have to scale the x/y here by window scale factor? // TODO(mitchellh): do we have to scale the x/y here by window scale factor?
} }
// Determine our momentum value let scrollEvent = Ghostty.Input.MouseScrollEvent(
var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE x: x,
switch (event.momentumPhase) { y: y,
case .began: mods: .init(precision: precision, momentum: .init(event.momentumPhase))
momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN )
case .stationary: surfaceModel.sendMouseScroll(scrollEvent)
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)
} }
override func pressureChange(with event: NSEvent) { override func pressureChange(with event: NSEvent) {
@ -1285,8 +1306,8 @@ extension Ghostty {
// In this case, AppKit calls menu BEFORE calling any mouse events. // In this case, AppKit calls menu BEFORE calling any mouse events.
// If mouse capturing is enabled then we never show the context menu // If mouse capturing is enabled then we never show the context menu
// so that we can handle ctrl+left-click in the terminal app. // so that we can handle ctrl+left-click in the terminal app.
guard let surface = self.surface else { return nil } guard let surfaceModel else { return nil }
if ghostty_surface_mouse_captured(surface) { if surfaceModel.mouseCaptured {
return nil return nil
} }
@ -1296,13 +1317,10 @@ extension Ghostty {
// //
// Note this never sounds a right mouse up event but that's the // Note this never sounds a right mouse up event but that's the
// same as normal right-click with capturing disabled from AppKit. // same as normal right-click with capturing disabled from AppKit.
let mods = Ghostty.ghosttyMods(event.modifierFlags) surfaceModel.sendMouseButton(.init(
ghostty_surface_mouse_button( action: .press,
surface, button: .right,
GHOSTTY_MOUSE_PRESS, mods: .init(nsFlags: event.modifierFlags)))
GHOSTTY_MOUSE_RIGHT,
mods
)
default: default:
return nil return nil
@ -1673,7 +1691,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
func insertText(_ string: Any, replacementRange: NSRange) { func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event // We must have an associated event
guard NSApp.currentEvent != nil else { return } 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 // We want the string view of the any value
var chars = "" var chars = ""
@ -1697,13 +1715,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return return
} }
let len = chars.utf8CString.count surfaceModel.sendText(chars)
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))
}
} }
/// This function needs to exist for two reasons: /// This function needs to exist for two reasons:
@ -1979,7 +1991,7 @@ extension Ghostty.SurfaceView {
/// Caches a value for some period of time, evicting it automatically when that time expires. /// 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 /// We use this to cache our surface content. This probably should be extracted some day
/// to a more generic helper. /// to a more generic helper.
fileprivate class CachedValue<T> { class CachedValue<T> {
private var value: T? private var value: T?
private let fetch: () -> T private let fetch: () -> T
private let duration: Duration private let duration: Duration

View File

@ -57,8 +57,10 @@ extension Ghostty {
// Setup our surface. This will also initialize all the terminal IO. // Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration() let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { ghostty_surface_new(app, &surface_cfg_c)
}
guard let surface = surface else {
// TODO // TODO
return return
} }

View File

@ -21,3 +21,28 @@ extension Array {
return i + 1 return i + 1
} }
} }
extension Array where Element == String {
/// Executes a closure with an array of C string pointers.
func withCStrings<T>(_ body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
// Handle empty array
if isEmpty {
return try body([])
}
// Recursive helper to process strings
func helper(index: Int, accumulated: [UnsafePointer<Int8>?], body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
if index == count {
return try body(accumulated)
}
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)
}
}

View File

@ -1,4 +1,5 @@
import AppKit import AppKit
import SwiftUI
extension NSView { extension NSView {
/// Returns true if this view is currently in the responder chain /// 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 // MARK: View Traversal and Search
extension NSView { extension NSView {

View File

@ -0,0 +1,10 @@
extension Optional where Wrapped == String {
/// Executes a closure with a C string pointer, handling nil gracefully.
func withCString<T>(_ body: (UnsafePointer<Int8>?) throws -> T) rethrows -> T {
if let string = self {
return try string.withCString(body)
} else {
return try body(nil)
}
}
}

View File

@ -0,0 +1,213 @@
import AppKit
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 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
static func show(
_ key: String,
message: String,
informative: String = "",
allowText: String = "Allow",
allowDuration: AllowDuration = .once,
rememberDuration: Duration? = .seconds(86400),
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 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, rememberDuration: rememberDuration, completion: completion)
}
} else {
let response = alert.runModal()
handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, 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
/// - 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: AllowDuration,
rememberDuration: 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, 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)
}
/// 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)
}
}
/// 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)
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")
}
}
}

View File

@ -376,6 +376,14 @@ pub const PlatformTag = enum(c_int) {
ios = 2, 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 { pub const Surface = struct {
app: *App, app: *App,
platform: Platform, platform: Platform,
@ -407,7 +415,7 @@ pub const Surface = struct {
font_size: f32 = 0, font_size: f32 = 0,
/// The working directory to load into. /// 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 command to run in the new surface. If this is set then
/// the "wait-after-command" option is also automatically set to true, /// 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. /// despite Ghostty allowing directly executed commands via config.
/// This is a legacy thing and we should probably change it in the /// This is a legacy thing and we should probably change it in the
/// future once we have a concrete use case. /// 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 { pub fn init(self: *Surface, app: *App, opts: Options) !void {
@ -443,7 +455,8 @@ pub const Surface = struct {
defer config.deinit(); defer config.deinit();
// If we have a working directory from the options then we set it. // If we have a working directory from the options then we set it.
const wd = std.mem.sliceTo(opts.working_directory, 0); if (opts.working_directory) |c_wd| {
const wd = std.mem.sliceTo(c_wd, 0);
if (wd.len > 0) wd: { if (wd.len > 0) wd: {
var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| { var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
log.warn( log.warn(
@ -472,13 +485,30 @@ pub const Surface = struct {
config.@"working-directory" = wd; config.@"working-directory" = wd;
} }
}
// If we have a command from the options then we set it. // If we have a command from the options then we set it.
const cmd = std.mem.sliceTo(opts.command, 0); if (opts.command) |c_command| {
const cmd = std.mem.sliceTo(c_command, 0);
if (cmd.len > 0) { if (cmd.len > 0) {
config.command = .{ .shell = cmd }; config.command = .{ .shell = cmd };
config.@"wait-after-command" = true; 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 // Initialize our surface right away. We're given a view that is
// ready to use. // ready to use.
@ -1837,12 +1867,10 @@ pub const CAPI = struct {
return false; 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 }); log.err("error performing binding action action={} err={}", .{ action, err });
return false; return false;
}; };
return true;
} }
/// Complete a clipboard read request started via the read callback. /// Complete a clipboard read request started via the read callback.

View File

@ -2355,6 +2355,29 @@ keybind: Keybinds = .{},
/// ///
@"macos-icon-screen-color": ?ColorList = null, @"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 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.
///
/// * `deny` - Deny Shortcuts from controlling Ghostty.
///
@"macos-shortcuts": MacShortcuts = .ask,
/// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// 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 /// This makes it so that resource management can be done on a per-surface
@ -3004,6 +3027,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 /// Change the state of conditionals and reload the configuration
/// based on the new state. This returns a new configuration based /// based on the new state. This returns a new configuration based
/// on the new state. The caller must free the old configuration if they /// on the new state. The caller must free the old configuration if they
@ -5956,6 +5984,13 @@ pub const MacAppIconFrame = enum {
chrome, chrome,
}; };
/// See macos-shortcuts
pub const MacShortcuts = enum {
allow,
deny,
ask,
};
/// See gtk-single-instance /// See gtk-single-instance
pub const GtkSingleInstance = enum { pub const GtkSingleInstance = enum {
desktop, desktop,