Merge branch 'main' into feat/fullscreen-non-native-config

pull/9876/head
Sachin Beniwal 2025-12-13 17:25:09 +05:30 committed by GitHub
commit ad1c6f4fbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 658 additions and 203 deletions

View File

@ -47,7 +47,7 @@ jobs:
/nix
/zig
- name: Setup Nix
uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

View File

@ -89,7 +89,7 @@ jobs:
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable

View File

@ -33,7 +33,7 @@ jobs:
with:
# Important so that build number generation works
fetch-depth: 0
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -166,7 +166,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

View File

@ -84,7 +84,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -127,7 +127,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -160,7 +160,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -194,7 +194,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -237,7 +237,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -273,7 +273,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -302,7 +302,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -335,7 +335,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -381,7 +381,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -600,7 +600,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -642,7 +642,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -690,7 +690,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -725,7 +725,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -789,7 +789,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -816,7 +816,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -844,7 +844,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -871,7 +871,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -898,7 +898,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -925,7 +925,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -952,7 +952,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -986,7 +986,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -1013,7 +1013,7 @@ jobs:
path: |
/nix
/zig
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -1050,7 +1050,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
@ -1138,7 +1138,7 @@ jobs:
/zig
# Install Nix and use that to run our tests so our environment matches exactly.
- uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
- uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

View File

@ -29,7 +29,7 @@ jobs:
/zig
- name: Setup Nix
uses: cachix/install-nix-action@0b0e072294b088b73964f1d72dfdac0951439dbd # v31.8.4
uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31.9.0
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16

View File

@ -17,14 +17,12 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
> [!IMPORTANT]
>
> If you are using **any kind of AI assistance** to contribute to Ghostty,
> it must be disclosed in the pull request.
> The Ghostty project allows AI-**assisted** _code contributions_, which
> must be properly disclosed in the pull request.
If you are using any kind of AI assistance while contributing to Ghostty,
**this must be disclosed in the pull request**, along with the extent to
which AI assistance was used (e.g. docs only vs. code generation).
As a small exception, trivial tab-completion doesn't need to be disclosed,
so long as it is limited to single keywords or short phrases.
The submitter must have also tested the pull request on all impacted
platforms, and it's **highly discouraged** to code for an unfamiliar platform
@ -32,10 +30,49 @@ with AI assistance alone: if you only have a macOS machine, do **not** ask AI
to write the equivalent GTK code, and vice versa — someone else with more
expertise will eventually get to it and do it for you.
Even though using AI to generate responses on a PR is allowed when properly
disclosed, **we do not encourage you to do so**. Often, the positive impact
of genuine, responsive human interaction more than makes up for any language
barrier. ❤️
> [!WARNING]
> **Note that AI _assistance_ does not equal AI _generation_**. We require
> a significant amount of human accountability, involvement and interaction
> even within AI-assisted contributions. Contributors are required to be able
> to understand the AI-assisted output, reason with it and answer critical
> questions about it. Should a PR see no visible human accountability and
> involvement, or it is so broken that it requires significant rework to be
> acceptable, **we reserve the right to close it without hesitation**.
**In addition, we currently restrict AI assistance to code changes only.**
No AI-generated media, e.g. artwork, icons, videos and other assets is
allowed, as it goes against the methodology and ethos behind Ghostty.
While AI-assisted code can help with productive prototyping, creative
inspiration and even automated bugfinding, we have currently found zero
benefit to AI-generated assets. Instead, we are far more interested and
invested in funding professional work done by human designers and artists.
If you intend to submit AI-generated assets to Ghostty, sorry,
we are not interested.
Likewise, all community interactions, including all comments on issues and
discussions and all PR titles and descriptions **must be composed by a human**.
Community moderators and Ghostty maintainers reserve the right to mark
AI-generated responses as spam or disruptive content, and ban users who have
been repeatedly caught relying entirely on LLMs during interactions.
> [!NOTE]
> If your English isn't the best and you are currently relying on an LLM to
> translate your responses, don't fret — usually we maintainers will be able
> to understand your messages well enough. We'd like to encourage real humans
> to interact with each other more, and the positive impact of genuine,
> responsive yet imperfect human interaction more than makes up for any
> language barrier.
>
> Please write your responses yourself, to the best of your ability.
> If you do feel the need to polish your sentences, however, please use
> dedicated translation software rather than an LLM.
>
> We greatly appreciate it. Thank you. ❤️
Minor exceptions to this policy include trivial AI-generated tab completion
functionality, as it usually does not impact the quality of the code and
do not need to be disclosed, and commit titles and messages, which are often
generated by AI coding agents.
An example disclosure:
@ -60,13 +97,6 @@ work than any human. That isn't the world we live in today, and in most cases
it's generating slop. I say this despite being a fan of and using them
successfully myself (with heavy supervision)!
When using AI assistance, we expect a fairly high level of accountability
and responsibility from contributors, and expect them to understand the code
that is produced and be able to answer critical questions about it. It
isn't a maintainers job to review a PR so broken that it requires
significant rework to be acceptable, and we **reserve the right to close
these PRs without hesitation**.
Please be respectful to maintainers and disclose AI assistance.
## Quick Guide

View File

@ -582,6 +582,12 @@ typedef enum {
GHOSTTY_QUIT_TIMER_STOP,
} ghostty_action_quit_timer_e;
// apprt.action.Readonly
typedef enum {
GHOSTTY_READONLY_OFF,
GHOSTTY_READONLY_ON,
} ghostty_action_readonly_e;
// apprt.action.DesktopNotification.C
typedef struct {
const char* title;
@ -593,6 +599,12 @@ typedef struct {
const char* title;
} ghostty_action_set_title_s;
// apprt.action.PromptTitle
typedef enum {
GHOSTTY_PROMPT_TITLE_SURFACE,
GHOSTTY_PROMPT_TITLE_TAB,
} ghostty_action_prompt_title_e;
// apprt.action.Pwd.C
typedef struct {
const char* pwd;
@ -840,7 +852,8 @@ typedef enum {
GHOSTTY_ACTION_END_SEARCH,
GHOSTTY_ACTION_SEARCH_TOTAL,
GHOSTTY_ACTION_SEARCH_SELECTED,
} ghostty_action_tag_e;
GHOSTTY_ACTION_READONLY,
} ghostty_action_tag_e;
typedef union {
ghostty_action_split_direction_e new_split;
@ -856,6 +869,7 @@ typedef union {
ghostty_action_inspector_e inspector;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_prompt_title_e prompt_title;
ghostty_action_pwd_s pwd;
ghostty_action_mouse_shape_e mouse_shape;
ghostty_action_mouse_visibility_e mouse_visibility;
@ -876,6 +890,7 @@ typedef union {
ghostty_action_start_search_s start_search;
ghostty_action_search_total_s search_total;
ghostty_action_search_selected_s search_selected;
ghostty_action_readonly_e readonly;
} ghostty_action_u;
typedef struct {

View File

@ -68,6 +68,8 @@ class AppDelegate: NSObject,
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@IBOutlet private var menuResetFontSize: NSMenuItem?
@IBOutlet private var menuChangeTitle: NSMenuItem?
@IBOutlet private var menuChangeTabTitle: NSMenuItem?
@IBOutlet private var menuReadonly: NSMenuItem?
@IBOutlet private var menuQuickTerminal: NSMenuItem?
@IBOutlet private var menuTerminalInspector: NSMenuItem?
@IBOutlet private var menuCommandPalette: NSMenuItem?
@ -541,8 +543,9 @@ class AppDelegate: NSObject,
self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
@ -609,6 +612,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle)
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)

View File

@ -16,6 +16,7 @@
<connections>
<outlet property="menuAbout" destination="5kV-Vb-QxS" id="Y5y-UO-NK6"/>
<outlet property="menuBringAllToFront" destination="LE2-aR-0XJ" id="AP9-oK-60V"/>
<outlet property="menuChangeTabTitle" destination="iac-lh-Cl7" id="tId-v0-a3E"/>
<outlet property="menuChangeTitle" destination="24I-xg-qIq" id="kg6-kT-jNL"/>
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
<outlet property="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/>
@ -46,6 +47,7 @@
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
<outlet property="menuReadonly" destination="xpe-ia-Yjw" id="MMT-Sl-AfD"/>
<outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
@ -315,12 +317,24 @@
<action selector="toggleCommandPalette:" target="-1" id="FcT-XD-gM1"/>
</connections>
</menuItem>
<menuItem title="Change Title..." id="24I-xg-qIq">
<menuItem title="Change Tab Title..." id="iac-lh-Cl7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="changeTabTitle:" target="-1" id="Jhl-9P-bMj"/>
</connections>
</menuItem>
<menuItem title="Change Terminal Title..." id="24I-xg-qIq">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="changeTitle:" target="-1" id="XuL-QB-Q9l"/>
</connections>
</menuItem>
<menuItem title="Terminal Read-only" id="xpe-ia-Yjw">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleReadonly:" target="-1" id="Gqx-wT-K9v"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="Vkj-tP-dMZ"/>
<menuItem title="Quick Terminal" id="1pv-LF-NBJ">
<modifierMask key="keyEquivalentModifierMask"/>

View File

@ -81,6 +81,15 @@ class BaseTerminalController: NSWindowController,
/// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
/// An override title for the tab/window set by the user via prompt_tab_title.
/// When set, this takes precedence over the computed title from the terminal.
var titleOverride: String? = nil {
didSet { applyTitleToWindow() }
}
/// The last computed title from the focused surface (without the override).
private var lastComputedTitle: String = "👻"
/// The time that undo/redo operations that contain running ptys are valid for.
var undoExpiration: Duration {
ghostty.config.undoTimeout
@ -325,6 +334,37 @@ class BaseTerminalController: NSWindowController,
self.alert = alert
}
/// Prompt the user to change the tab/window title.
func promptTabTitle() {
guard let window else { return }
let alert = NSAlert()
alert.messageText = "Change Tab Title"
alert.informativeText = "Leave blank to restore the default."
alert.alertStyle = .informational
let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24))
textField.stringValue = titleOverride ?? window.title
alert.accessoryView = textField
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = textField
alert.beginSheetModal(for: window) { [weak self] response in
guard let self else { return }
guard response == .alertFirstButtonReturn else { return }
let newTitle = textField.stringValue
if newTitle.isEmpty {
self.titleOverride = nil
} else {
self.titleOverride = newTitle
}
}
}
/// Close a surface from a view.
func closeSurface(
_ view: Ghostty.SurfaceView,
@ -718,10 +758,13 @@ class BaseTerminalController: NSWindowController,
}
private func titleDidChange(to: String) {
lastComputedTitle = to
applyTitleToWindow()
}
private func applyTitleToWindow() {
guard let window else { return }
// Set the main window title
window.title = to
window.title = titleOverride ?? lastComputedTitle
}
func pwdDidChange(to: URL?) {
@ -1017,6 +1060,10 @@ class BaseTerminalController: NSWindowController,
window.performClose(sender)
}
@IBAction func changeTabTitle(_ sender: Any) {
promptTabTitle()
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT)

View File

@ -4,18 +4,20 @@ import Cocoa
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 6
static let version: Int = 7
let focusedSurface: String?
let surfaceTree: SplitTree<Ghostty.SurfaceView>
let effectiveFullscreenMode: FullscreenMode?
let tabColor: TerminalTabColor
let titleOverride: String?
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.id.uuidString
self.surfaceTree = controller.surfaceTree
self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode
self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none
self.titleOverride = controller.titleOverride
}
init?(coder aDecoder: NSCoder) {
@ -34,6 +36,7 @@ class TerminalRestorableState: Codable {
self.focusedSurface = v.value.focusedSurface
self.effectiveFullscreenMode = v.value.effectiveFullscreenMode
self.tabColor = v.value.tabColor
self.titleOverride = v.value.titleOverride
}
func encode(with coder: NSCoder) {
@ -100,6 +103,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
// Restore our tab color
(window as? TerminalWindow)?.tabColor = state.tabColor
// Restore the tab title override
c.titleOverride = state.titleOverride
// Setup our restored state on the controller
// Find the focused surface in surfaceTree
if let focusedStr = state.focusedSurface {

View File

@ -120,6 +120,9 @@ struct TabColorMenuView: View {
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text("Tab Color")
.padding(.bottom, 2)
ForEach(Self.paletteRows, id: \.self) { row in
HStack(spacing: 2) {
ForEach(row, id: \.self) { color in

View File

@ -668,8 +668,9 @@ private struct TabColorIndicatorView: View {
extension TerminalWindow {
private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem")
private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem")
private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator")
private static let tabColorHeaderIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorHeader")
private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette")
func configureTabContextMenuIfNeeded(_ menu: NSMenu) {
@ -701,14 +702,14 @@ extension TerminalWindow {
}
}
appendTabColorSection(to: menu, target: targetController)
appendTabModifierSection(to: menu, target: targetController)
}
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
guard NSApp.keyWindow === self else { return false }
// These are the target selectors, at least for macOS 26.
let tabContextSelectors: Set<String> = [
// These selectors must all exist for it to be a tab context menu.
let requiredSelectors: Set<String> = [
"performClose:",
"performCloseOtherTabs:",
"moveTabToNewWindow:",
@ -716,13 +717,13 @@ extension TerminalWindow {
]
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
return !selectorNames.isDisjoint(with: tabContextSelectors)
return requiredSelectors.isSubset(of: selectorNames)
}
private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) {
private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) {
menu.removeItems(withIdentifiers: [
Self.tabColorSeparatorIdentifier,
Self.tabColorHeaderIdentifier,
Self.changeTitleMenuItemIdentifier,
Self.tabColorPaletteIdentifier
])
@ -730,17 +731,17 @@ extension TerminalWindow {
separator.identifier = Self.tabColorSeparatorIdentifier
menu.addItem(separator)
let headerItem = NSMenuItem()
headerItem.identifier = Self.tabColorHeaderIdentifier
headerItem.title = "Tab Color"
headerItem.isEnabled = false
headerItem.setImageIfDesired(systemSymbolName: "eyedropper")
menu.addItem(headerItem)
// Change Title...
let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
changeTitleItem.target = target
changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line")
menu.addItem(changeTitleItem)
let paletteItem = NSMenuItem()
paletteItem.identifier = Self.tabColorPaletteIdentifier
paletteItem.view = makeTabColorPaletteView(
selectedColor: tabColor
selectedColor: (target?.window as? TerminalWindow)?.tabColor ?? .none
) { [weak target] color in
(target?.window as? TerminalWindow)?.tabColor = color
}

View File

@ -127,6 +127,20 @@ extension Ghostty.Action {
}
}
}
enum PromptTitle {
case surface
case tab
init(_ c: ghostty_action_prompt_title_e) {
switch c {
case GHOSTTY_PROMPT_TITLE_TAB:
self = .tab
default:
self = .surface
}
}
}
}
// Putting the initializer in an extension preserves the automatic one.

View File

@ -523,7 +523,7 @@ extension Ghostty {
setTitle(app, target: target, v: action.action.set_title)
case GHOSTTY_ACTION_PROMPT_TITLE:
return promptTitle(app, target: target)
return promptTitle(app, target: target, v: action.action.prompt_title)
case GHOSTTY_ACTION_PWD:
pwdChanged(app, target: target, v: action.action.pwd)
@ -588,6 +588,9 @@ extension Ghostty {
case GHOSTTY_ACTION_RING_BELL:
ringBell(app, target: target)
case GHOSTTY_ACTION_READONLY:
setReadonly(app, target: target, v: action.action.readonly)
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
checkForUpdates(app)
@ -1010,6 +1013,31 @@ extension Ghostty {
}
}
private static func setReadonly(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_readonly_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set readonly does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyDidChangeReadonly,
object: surfaceView,
userInfo: [
SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON,
]
)
default:
assertionFailure()
}
}
private static func moveTab(
_ app: ghostty_app_t,
target: ghostty_target_s,
@ -1350,22 +1378,50 @@ extension Ghostty {
private static func promptTitle(
_ app: ghostty_app_t,
target: ghostty_target_s) -> Bool {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set title prompt does nothing with an app target")
return false
target: ghostty_target_s,
v: ghostty_action_prompt_title_e) -> Bool {
let promptTitle = Action.PromptTitle(v)
switch promptTitle {
case .surface:
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set title prompt does nothing with an app target")
return false
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
surfaceView.promptTitle()
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
surfaceView.promptTitle()
return true
default:
assertionFailure()
default:
assertionFailure()
return false
}
case .tab:
switch (target.tag) {
case GHOSTTY_TARGET_APP:
guard let window = NSApp.mainWindow ?? NSApp.keyWindow,
let controller = window.windowController as? BaseTerminalController
else { return false }
controller.promptTabTitle()
return true
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let window = surfaceView.window,
let controller = window.windowController as? BaseTerminalController
else { return false }
controller.promptTabTitle()
return true
default:
assertionFailure()
return false
}
}
return true
}
private static func pwdChanged(

View File

@ -391,6 +391,10 @@ extension Notification.Name {
/// Ring the bell
static let ghosttyBellDidRing = Notification.Name("com.mitchellh.ghostty.ghosttyBellDidRing")
/// Readonly mode changed
static let ghosttyDidChangeReadonly = Notification.Name("com.mitchellh.ghostty.didChangeReadonly")
static let ReadonlyKey = ghosttyDidChangeReadonly.rawValue + ".readonly"
static let ghosttyCommandPaletteDidToggle = Notification.Name("com.mitchellh.ghostty.commandPaletteDidToggle")
/// Toggle maximize of current window

View File

@ -116,6 +116,13 @@ extension Ghostty {
}
#if canImport(AppKit)
// Readonly indicator badge
if surfaceView.readonly {
ReadonlyBadge {
surfaceView.toggleReadonly(nil)
}
}
// If we are in the middle of a key sequence, then we show a visual element. We only
// support this on macOS currently although in theory we can support mobile with keyboards!
if !surfaceView.keySequence.isEmpty {
@ -757,6 +764,96 @@ extension Ghostty {
}
}
// MARK: Readonly Badge
/// A badge overlay that indicates a surface is in readonly mode.
/// Positioned in the top-right corner and styled to be noticeable but unobtrusive.
struct ReadonlyBadge: View {
let onDisable: () -> Void
@State private var showingPopover = false
private let badgeColor = Color(hue: 0.08, saturation: 0.5, brightness: 0.8)
var body: some View {
VStack {
HStack {
Spacer()
HStack(spacing: 5) {
Image(systemName: "eye.fill")
.font(.system(size: 12))
Text("Read-only")
.font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(badgeBackground)
.foregroundStyle(badgeColor)
.onTapGesture {
showingPopover = true
}
.backport.pointerStyle(.link)
.popover(isPresented: $showingPopover, arrowEdge: .bottom) {
ReadonlyPopoverView(onDisable: onDisable, isPresented: $showingPopover)
}
}
.padding(8)
Spacer()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Read-only terminal")
}
private var badgeBackground: some View {
RoundedRectangle(cornerRadius: 6)
.fill(.regularMaterial)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.orange.opacity(0.6), lineWidth: 1.5)
)
}
}
struct ReadonlyPopoverView: View {
let onDisable: () -> Void
@Binding var isPresented: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "eye.fill")
.foregroundColor(.orange)
.font(.system(size: 13))
Text("Read-Only Mode")
.font(.system(size: 13, weight: .semibold))
}
Text("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Disable") {
onDisable()
isPresented = false
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
.frame(width: 280)
}
}
#if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't

View File

@ -123,6 +123,9 @@ extension Ghostty {
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize? = nil
@ -333,6 +336,11 @@ extension Ghostty {
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidChangeReadonly(_:)),
name: .ghosttyDidChangeReadonly,
object: self)
center.addObserver(
self,
selector: #selector(windowDidChangeScreen),
@ -703,6 +711,11 @@ extension Ghostty {
bell = true
}
@objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) {
guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return }
readonly = value
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }
@ -1416,9 +1429,13 @@ extension Ghostty {
item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise")
item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "scope")
item = menu.addItem(withTitle: "Terminal Read-only", action: #selector(toggleReadonly(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "eye.fill")
item.state = readonly ? .on : .off
menu.addItem(.separator())
item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
item.setImageIfDesired(systemSymbolName: "pencil.line")
item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
return menu
}
@ -1498,6 +1515,14 @@ extension Ghostty {
}
}
@IBAction func toggleReadonly(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "toggle_readonly"
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)
@ -1974,6 +1999,10 @@ extension Ghostty.SurfaceView: NSMenuItemValidation {
case #selector(findHide):
return searchState != nil
case #selector(toggleReadonly):
item.state = readonly ? .on : .off
return true
default:
return true
}

View File

@ -43,6 +43,9 @@ extension Ghostty {
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? = nil
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.

View File

@ -145,6 +145,12 @@ focused: bool = true,
/// Used to determine whether to continuously scroll.
selection_scroll_active: bool = false,
/// True if the surface is in read-only mode. When read-only, no input
/// is sent to the PTY but terminal-level operations like selections,
/// (native) scrolling, and copy keybinds still work. Warn before quit is
/// always enabled in this state.
readonly: bool = false,
/// Used to send notifications that long running commands have finished.
/// Requires that shell integration be active. Should represent a nanosecond
/// precision timestamp. It does not necessarily need to correspond to the
@ -814,6 +820,30 @@ inline fn surfaceMailbox(self: *Surface) Mailbox {
};
}
/// Queue a message for the IO thread.
///
/// We centralize all our logic into this spot so we can intercept
/// messages for example in readonly mode.
fn queueIo(
self: *Surface,
msg: termio.Message,
mutex: termio.Termio.MutexState,
) void {
// In readonly mode, we don't allow any writes through to the pty.
if (self.readonly) {
switch (msg) {
.write_small,
.write_stable,
.write_alloc,
=> return,
else => {},
}
}
self.io.queueMessage(msg, mutex);
}
/// Forces the surface to render. This is useful for when the surface
/// is in the middle of animation (such as a resize, etc.) or when
/// the render timer is managed manually by the apprt.
@ -845,7 +875,7 @@ pub fn activateInspector(self: *Surface) !void {
// Notify our components we have an inspector active
_ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} });
self.io.queueMessage(.{ .inspector = true }, .unlocked);
self.queueIo(.{ .inspector = true }, .unlocked);
}
/// Deactivate the inspector and stop collecting any information.
@ -862,7 +892,7 @@ pub fn deactivateInspector(self: *Surface) void {
// Notify our components we have deactivated inspector
_ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} });
self.io.queueMessage(.{ .inspector = false }, .unlocked);
self.queueIo(.{ .inspector = false }, .unlocked);
// Deinit the inspector
insp.deinit();
@ -873,6 +903,9 @@ pub fn deactivateInspector(self: *Surface) void {
/// True if the surface requires confirmation to quit. This should be called
/// by apprt to determine if the surface should confirm before quitting.
pub fn needsConfirmQuit(self: *Surface) bool {
// If the surface is in read-only mode, always require confirmation
if (self.readonly) return true;
// If the child has exited, then our process is certainly not alive.
// We check this first to avoid the locking overhead below.
if (self.child_exited) return false;
@ -931,7 +964,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
// We always use an allocating message because we don't know
// the length of the title and this isn't a performance critical
// path.
self.io.queueMessage(.{
self.queueIo(.{
.write_alloc = .{
.alloc = self.alloc,
.data = data,
@ -1123,7 +1156,7 @@ fn selectionScrollTick(self: *Surface) !void {
// If our screen changed while this is happening, we stop our
// selection scroll.
if (self.mouse.left_click_screen != t.screens.active_key) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
@ -1355,7 +1388,7 @@ fn reportColorScheme(self: *Surface, force: bool) void {
.dark => "\x1B[?997;1n",
};
self.io.queueMessage(.{ .write_stable = output }, .unlocked);
self.queueIo(.{ .write_stable = output }, .unlocked);
}
fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void {
@ -1728,7 +1761,7 @@ pub fn updateConfig(
errdefer termio_config_ptr.deinit();
_ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} });
self.io.queueMessage(.{
self.queueIo(.{
.change_config = .{
.alloc = self.alloc,
.ptr = termio_config_ptr,
@ -2294,7 +2327,7 @@ fn setCellSize(self: *Surface, size: rendererpkg.CellSize) !void {
self.balancePaddingIfNeeded();
// Notify the terminal
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
self.queueIo(.{ .resize = self.size }, .unlocked);
// Update our terminal default size if necessary.
self.recomputeInitialSize() catch |err| {
@ -2397,7 +2430,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void {
}
// Mail the IO thread
self.io.queueMessage(.{ .resize = self.size }, .unlocked);
self.queueIo(.{ .resize = self.size }, .unlocked);
}
/// Recalculate the balanced padding if needed.
@ -2673,7 +2706,7 @@ pub fn keyCallback(
}
errdefer write_req.deinit();
self.io.queueMessage(switch (write_req) {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
@ -2902,7 +2935,7 @@ fn endKeySequence(
if (self.keyboard.queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
self.io.queueMessage(switch (write_req) {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
.alloc => |v| .{ .write_alloc = v },
@ -3128,7 +3161,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void {
self.renderer_state.mutex.lock();
self.io.terminal.flags.focused = focused;
self.renderer_state.mutex.unlock();
self.io.queueMessage(.{ .focused = focused }, .unlocked);
self.queueIo(.{ .focused = focused }, .unlocked);
}
}
@ -3292,7 +3325,7 @@ pub fn scrollCallback(
};
};
for (0..y.magnitude()) |_| {
self.io.queueMessage(.{ .write_stable = seq }, .locked);
self.queueIo(.{ .write_stable = seq }, .locked);
}
}
@ -3513,7 +3546,7 @@ fn mouseReport(
data[5] = 32 + @as(u8, @intCast(viewport_point.y)) + 1;
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = 6,
} }, .locked);
@ -3536,7 +3569,7 @@ fn mouseReport(
i += try std.unicode.utf8Encode(@intCast(32 + viewport_point.y + 1), data[i..]);
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(i),
} }, .locked);
@ -3557,7 +3590,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@ -3574,7 +3607,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@ -3603,7 +3636,7 @@ fn mouseReport(
});
// Ask our IO thread to write the data
self.io.queueMessage(.{ .write_small = .{
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
@ -3755,7 +3788,7 @@ pub fn mouseButtonCallback(
// Stop selection scrolling when releasing the left mouse button
// but only when selection scrolling is active.
if (self.selection_scroll_active) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.unlocked,
);
@ -4112,7 +4145,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B";
};
for (0..@abs(path.y)) |_| {
self.io.queueMessage(.{ .write_stable = arrow }, .locked);
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
if (path.x != 0) {
@ -4122,7 +4155,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
};
for (0..@abs(path.x)) |_| {
self.io.queueMessage(.{ .write_stable = arrow }, .locked);
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
}
@ -4395,7 +4428,7 @@ pub fn cursorPosCallback(
// Stop selection scrolling when inside the viewport within a 1px buffer
// for fullscreen windows, but only when selection scrolling is active.
if (pos.y >= 1 and self.selection_scroll_active) {
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
@ -4495,7 +4528,7 @@ pub fn cursorPosCallback(
if ((pos.y <= 1 or pos.y > max_y - 1) and
!self.selection_scroll_active)
{
self.io.queueMessage(
self.queueIo(
.{ .selection_scroll = true },
.locked,
);
@ -4871,7 +4904,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.esc => try std.fmt.bufPrint(&buf, "\x1b{s}", .{data}),
else => unreachable,
};
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
full_data,
), .unlocked);
@ -4898,7 +4931,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
);
return true;
};
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
text,
), .unlocked);
@ -4931,9 +4964,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
if (normal) {
self.io.queueMessage(.{ .write_stable = ck.normal }, .unlocked);
self.queueIo(.{ .write_stable = ck.normal }, .unlocked);
} else {
self.io.queueMessage(.{ .write_stable = ck.application }, .unlocked);
self.queueIo(.{ .write_stable = ck.application }, .unlocked);
}
},
@ -5185,7 +5218,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.prompt_surface_title => return try self.rt_app.performAction(
.{ .surface = self },
.prompt_title,
{},
.surface,
),
.prompt_tab_title => return try self.rt_app.performAction(
.{ .surface = self },
.prompt_title,
.tab,
),
.clear_screen => {
@ -5200,19 +5239,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
if (self.io.terminal.screens.active_key == .alternate) return false;
}
self.io.queueMessage(.{
self.queueIo(.{
.clear_screen = .{ .history = true },
}, .unlocked);
},
.scroll_to_top => {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .top = {} },
}, .unlocked);
},
.scroll_to_bottom => {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .bottom = {} },
}, .unlocked);
},
@ -5242,14 +5281,14 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.scroll_page_up => {
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = -1 * rows },
}, .unlocked);
},
.scroll_page_down => {
const rows: isize = @intCast(self.size.grid().rows);
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = rows },
}, .unlocked);
},
@ -5257,19 +5296,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.scroll_page_fractional => |fraction| {
const rows: f32 = @floatFromInt(self.size.grid().rows);
const delta: isize = @intFromFloat(@trunc(fraction * rows));
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = delta },
}, .unlocked);
},
.scroll_page_lines => |lines| {
self.io.queueMessage(.{
self.queueIo(.{
.scroll_viewport = .{ .delta = lines },
}, .unlocked);
},
.jump_to_prompt => |delta| {
self.io.queueMessage(.{
self.queueIo(.{
.jump_to_prompt = @intCast(delta),
}, .unlocked);
},
@ -5379,6 +5418,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.toggle_readonly => {
self.readonly = !self.readonly;
_ = try self.rt_app.performAction(
.{ .surface = self },
.readonly,
if (self.readonly) .on else .off,
);
return true;
},
.reset_window_size => return try self.rt_app.performAction(
.{ .surface = self },
.reset_window_size,
@ -5484,7 +5533,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
},
.io => self.io.queueMessage(.{ .crash = {} }, .unlocked),
.io => self.queueIo(.{ .crash = {} }, .unlocked),
},
.adjust_selection => |direction| {
@ -5682,7 +5731,7 @@ fn writeScreenFile(
},
.url = path,
}),
.paste => self.io.queueMessage(try termio.Message.writeReq(
.paste => self.queueIo(try termio.Message.writeReq(
self.alloc,
path,
), .unlocked),
@ -5822,7 +5871,7 @@ fn completeClipboardPaste(
};
for (vecs) |vec| if (vec.len > 0) {
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
vec,
), .unlocked);
@ -5868,7 +5917,7 @@ fn completeClipboardReadOSC52(
const encoded = enc.encode(buf[prefix.len..], data);
assert(encoded.len == size);
self.io.queueMessage(try termio.Message.writeReq(
self.queueIo(try termio.Message.writeReq(
self.alloc,
buf,
), .unlocked);

View File

@ -189,8 +189,9 @@ pub const Action = union(Key) {
set_title: SetTitle,
/// Set the title of the target to a prompted value. It is up to
/// the apprt to prompt.
prompt_title,
/// the apprt to prompt. The value specifies whether to prompt for the
/// surface title or the tab title.
prompt_title: PromptTitle,
/// The current working directory has changed for the target terminal.
pwd: Pwd,
@ -313,6 +314,9 @@ pub const Action = union(Key) {
/// The currently selected search match index (1-based).
search_selected: SearchSelected,
/// The readonly state of the surface has changed.
readonly: Readonly,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@ -374,6 +378,7 @@ pub const Action = union(Key) {
end_search,
search_total,
search_selected,
readonly,
};
/// Sync with: ghostty_action_u
@ -531,11 +536,22 @@ pub const QuitTimer = enum(c_int) {
stop,
};
pub const Readonly = enum(c_int) {
off,
on,
};
pub const MouseVisibility = enum(c_int) {
visible,
hidden,
};
/// Whether to prompt for the surface title or tab title.
pub const PromptTitle = enum(c_int) {
surface,
tab,
};
pub const MouseOverLink = struct {
url: [:0]const u8,

View File

@ -693,7 +693,7 @@ pub const Application = extern struct {
.progress_report => return Action.progressReport(target, value),
.prompt_title => return Action.promptTitle(target),
.prompt_title => return Action.promptTitle(target, value),
.quit => self.quit(),
@ -746,6 +746,7 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.readonly,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@ -2250,12 +2251,18 @@ const Action = struct {
};
}
pub fn promptTitle(target: apprt.Target) bool {
switch (target) {
.app => return false,
.surface => |v| {
v.rt_surface.surface.promptTitle();
return true;
pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool {
switch (value) {
.surface => switch (target) {
.app => return false,
.surface => |v| {
v.rt_surface.surface.promptTitle();
return true;
},
},
.tab => {
// GTK does not yet support tab title prompting
return false;
},
}
}

View File

@ -2842,7 +2842,7 @@ keybind: Keybinds = .{},
/// also known as the traffic lights, that allow you to close, miniaturize, and
/// zoom the window.
///
/// This setting has no effect when `window-decoration = false` or
/// This setting has no effect when `window-decoration = none` or
/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in
/// these modes.
///
@ -2883,7 +2883,7 @@ keybind: Keybinds = .{},
/// macOS 14 does not have this issue and any other macOS version has not
/// been tested.
///
/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`,
/// The "hidden" style hides the titlebar. Unlike `window-decoration = none`,
/// however, it does not remove the frame from the window or cause it to have
/// squared corners. Changing to or from this option at run-time may affect
/// existing windows in buggy ways.
@ -3222,7 +3222,7 @@ else
/// manager's simple titlebar. The behavior of this option will vary with your
/// window manager.
///
/// This option does nothing when `window-decoration` is false or when running
/// This option does nothing when `window-decoration` is none or when running
/// under macOS.
@"gtk-titlebar": bool = true,

View File

@ -519,6 +519,11 @@ pub const Action = union(enum) {
/// version can be found by running `ghostty +version`.
prompt_surface_title,
/// Change the title of the current tab/window via a pop-up prompt. The
/// title set via this prompt overrides any title set by the terminal
/// and persists across focus changes within the tab.
prompt_tab_title,
/// Create a new split in the specified direction.
///
/// Valid arguments:
@ -547,6 +552,16 @@ pub const Action = union(enum) {
/// reflect this by displaying an icon indicating the zoomed state.
toggle_split_zoom,
/// Toggle read-only mode for the current surface.
///
/// When a surface is in read-only mode:
/// - No input is sent to the PTY (mouse events, key encoding)
/// - Input can still be used at the terminal level to make selections,
/// copy/paste (keybinds), scroll, etc.
/// - Warn before quit is always enabled in this state even if an active
/// process is not running
toggle_readonly,
/// Resize the current split in the specified direction and amount in
/// pixels. The two arguments should be joined with a comma (`,`),
/// like in `resize_split:up,10`.
@ -1191,6 +1206,7 @@ pub const Action = union(enum) {
.reset_font_size,
.set_font_size,
.prompt_surface_title,
.prompt_tab_title,
.clear_screen,
.select_all,
.scroll_to_top,
@ -1235,6 +1251,7 @@ pub const Action = union(enum) {
.new_split,
.goto_split,
.toggle_split_zoom,
.toggle_readonly,
.resize_split,
.equalize_splits,
.inspector,

View File

@ -413,10 +413,16 @@ fn actionCommands(action: Action.Key) []const Command {
.prompt_surface_title => comptime &.{.{
.action = .prompt_surface_title,
.title = "Change Title...",
.title = "Change Terminal Title...",
.description = "Prompt for a new title for the current terminal.",
}},
.prompt_tab_title => comptime &.{.{
.action = .prompt_tab_title,
.title = "Change Tab Title...",
.description = "Prompt for a new title for the current tab.",
}},
.new_split => comptime &.{
.{
.action = .{ .new_split = .left },
@ -479,6 +485,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Toggle the zoom state of the current split.",
}},
.toggle_readonly => comptime &.{.{
.action = .toggle_readonly,
.title = "Toggle Read-Only Mode",
.description = "Toggle read-only mode for the current surface.",
}},
.equalize_splits => comptime &.{.{
.action = .equalize_splits,
.title = "Equalize Splits",

View File

@ -1,7 +1,84 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
/// Builder for constructing space-separated shell command strings.
/// Uses a caller-provided allocator (typically with stackFallback).
pub const ShellCommandBuilder = struct {
buffer: std.Io.Writer.Allocating,
pub fn init(allocator: Allocator) ShellCommandBuilder {
return .{ .buffer = .init(allocator) };
}
pub fn deinit(self: *ShellCommandBuilder) void {
self.buffer.deinit();
}
/// Append an argument to the command with automatic space separation.
pub fn appendArg(self: *ShellCommandBuilder, arg: []const u8) (Allocator.Error || Writer.Error)!void {
if (arg.len == 0) return;
if (self.buffer.written().len > 0) {
try self.buffer.writer.writeByte(' ');
}
try self.buffer.writer.writeAll(arg);
}
/// Get the final null-terminated command string, transferring ownership to caller.
/// Calling deinit() after this is safe but unnecessary.
pub fn toOwnedSlice(self: *ShellCommandBuilder) Allocator.Error![:0]const u8 {
return try self.buffer.toOwnedSliceSentinel(0);
}
};
test ShellCommandBuilder {
// Empty command
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try testing.expectEqualStrings("", cmd.buffer.written());
}
// Single arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// Multiple args
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
try cmd.appendArg("-l");
try testing.expectEqualStrings("bash --posix -l", cmd.buffer.written());
}
// Empty arg
{
var cmd = ShellCommandBuilder.init(testing.allocator);
defer cmd.deinit();
try cmd.appendArg("bash");
try cmd.appendArg("");
try testing.expectEqualStrings("bash", cmd.buffer.written());
}
// toOwnedSlice
{
var cmd = ShellCommandBuilder.init(testing.allocator);
try cmd.appendArg("bash");
try cmd.appendArg("--posix");
const result = try cmd.toOwnedSlice();
defer testing.allocator.free(result);
try testing.expectEqualStrings("bash --posix", result);
try testing.expectEqual(@as(u8, 0), result[result.len]);
}
}
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.

View File

@ -22,6 +22,9 @@ const configpkg = @import("../config.zig");
const log = std.log.scoped(.io_exec);
/// Mutex state argument for queueMessage.
pub const MutexState = enum { locked, unlocked };
/// Allocator
alloc: Allocator,
@ -380,7 +383,7 @@ pub fn threadExit(self: *Termio, data: *ThreadData) void {
pub fn queueMessage(
self: *Termio,
msg: termio.Message,
mutex: enum { locked, unlocked },
mutex: MutexState,
) void {
self.mailbox.send(msg, switch (mutex) {
.locked => self.renderer_state.mutex,

View File

@ -259,8 +259,9 @@ fn setupBash(
resource_dir: []const u8,
env: *EnvMap,
) !?config.Command {
var args: std.ArrayList([:0]const u8) = try .initCapacity(alloc, 3);
defer args.deinit(alloc);
var stack_fallback = std.heap.stackFallback(4096, alloc);
var cmd = internal_os.shell.ShellCommandBuilder.init(stack_fallback.get());
defer cmd.deinit();
// Iterator that yields each argument in the original command line.
// This will allocate once proportionate to the command line length.
@ -269,14 +270,9 @@ fn setupBash(
// Start accumulating arguments with the executable and initial flags.
if (iter.next()) |exe| {
try args.append(alloc, try alloc.dupeZ(u8, exe));
try cmd.appendArg(exe);
} else return null;
try args.append(alloc, "--posix");
// On macOS, we request a login shell to match that platform's norms.
if (comptime builtin.target.os.tag.isDarwin()) {
try args.append(alloc, "--login");
}
try cmd.appendArg("--posix");
// Stores the list of intercepted command line flags that will be passed
// to our shell integration script: --norc --noprofile
@ -309,17 +305,17 @@ fn setupBash(
if (std.mem.indexOfScalar(u8, arg, 'c') != null) {
return null;
}
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
} else if (std.mem.eql(u8, arg, "-") or std.mem.eql(u8, arg, "--")) {
// All remaining arguments should be passed directly to the shell
// command. We shouldn't perform any further option processing.
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
while (iter.next()) |remaining_arg| {
try args.append(alloc, try alloc.dupeZ(u8, remaining_arg));
try cmd.appendArg(remaining_arg);
}
break;
} else {
try args.append(alloc, try alloc.dupeZ(u8, arg));
try cmd.appendArg(arg);
}
}
try env.put("GHOSTTY_BASH_INJECT", buf[0..inject.end]);
@ -357,9 +353,11 @@ fn setupBash(
);
try env.put("ENV", integ_dir);
// Since we built up a command line, we don't need to wrap it in
// ANOTHER shell anymore and can do a direct command.
return .{ .direct = try args.toOwnedSlice(alloc) };
// Get the command string from the builder, then copy it to the arena
// allocator. The stackFallback allocator's memory becomes invalid after
// this function returns, so we must copy to the arena.
const cmd_str = try cmd.toOwnedSlice();
return .{ .shell = try alloc.dupeZ(u8, cmd_str) };
}
test "bash" {
@ -373,12 +371,7 @@ test "bash" {
const command = try setupBash(alloc, .{ .shell = "bash" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?);
try testing.expectEqualStrings("1", env.get("GHOSTTY_BASH_INJECT").?);
}
@ -421,12 +414,7 @@ test "bash: inject flags" {
const command = try setupBash(alloc, .{ .shell = "bash --norc" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("1 --norc", env.get("GHOSTTY_BASH_INJECT").?);
}
@ -437,12 +425,7 @@ test "bash: inject flags" {
const command = try setupBash(alloc, .{ .shell = "bash --noprofile" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("1 --noprofile", env.get("GHOSTTY_BASH_INJECT").?);
}
}
@ -459,24 +442,14 @@ test "bash: rcfile" {
// bash --rcfile
{
const command = try setupBash(alloc, .{ .shell = "bash --rcfile profile.sh" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
// bash --init-file
{
const command = try setupBash(alloc, .{ .shell = "bash --init-file profile.sh" }, ".", &env);
try testing.expect(command.?.direct.len >= 2);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
try testing.expectEqualStrings("bash --posix", command.?.shell);
try testing.expectEqualStrings("profile.sh", env.get("GHOSTTY_BASH_RCFILE").?);
}
}
@ -538,35 +511,13 @@ test "bash: additional arguments" {
// "-" argument separator
{
const command = try setupBash(alloc, .{ .shell = "bash - --arg file1 file2" }, ".", &env);
try testing.expect(command.?.direct.len >= 6);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2;
try testing.expectEqualStrings("-", command.?.direct[offset + 0]);
try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]);
try testing.expectEqualStrings("file1", command.?.direct[offset + 2]);
try testing.expectEqualStrings("file2", command.?.direct[offset + 3]);
try testing.expectEqualStrings("bash --posix - --arg file1 file2", command.?.shell);
}
// "--" argument separator
{
const command = try setupBash(alloc, .{ .shell = "bash -- --arg file1 file2" }, ".", &env);
try testing.expect(command.?.direct.len >= 6);
try testing.expectEqualStrings("bash", command.?.direct[0]);
try testing.expectEqualStrings("--posix", command.?.direct[1]);
if (comptime builtin.target.os.tag.isDarwin()) {
try testing.expectEqualStrings("--login", command.?.direct[2]);
}
const offset = if (comptime builtin.target.os.tag.isDarwin()) 3 else 2;
try testing.expectEqualStrings("--", command.?.direct[offset + 0]);
try testing.expectEqualStrings("--arg", command.?.direct[offset + 1]);
try testing.expectEqualStrings("file1", command.?.direct[offset + 2]);
try testing.expectEqualStrings("file2", command.?.direct[offset + 3]);
try testing.expectEqualStrings("bash --posix -- --arg file1 file2", command.?.shell);
}
}