Merge branch 'main' into main

pull/12604/head
Mohammad AlShami 2026-06-03 09:39:54 +03:00 committed by GitHub
commit e1d3a7a05c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
126 changed files with 12223 additions and 1979 deletions

21
.github/VOUCHED.td vendored
View File

@ -20,9 +20,11 @@
# "!denounce" or "!denounce [username]" on a discussion.
00-kat
007hacky007
00jciv00
04cb
0xdvc
-4rh1t3ct0r7
52dyd
aalhendi
aaron-ang
abdurrahmanski
@ -43,7 +45,10 @@ andrejdaskalov
anhthang
anmitalidev
anthonyzhoon
athaapa
atomk
b0uks
b1nar10
balazs-szucs
barutsrb
bch
@ -57,6 +62,7 @@ bleikurr
bo2themax
brentschroeter
brianc442
c0x0o
cespare
charliie-dev
chernetskyi
@ -65,6 +71,7 @@ cmwetherell
crayxt
craziestowl
curtismoncoq
-cznorth Automated advertising + likely AI communication
d-dudas
-daedaevibin
daiimus
@ -91,8 +98,10 @@ elias8
-enkr1
enzowilliam
ephemera
-eric-assetpass Try talking, not botting
eriksremess
erral
-f1813483-netizen
faukah
filip7
flou
@ -151,6 +160,7 @@ kristofersoler
kylesower
laxystem
lebdron
lepips
liby
linustalacko
lonsagisawa
@ -164,6 +174,7 @@ markdorison
markhuot
marler8997
marrocco-simone
masterflitzer
matkotiric
mattn
micaeljarniac
@ -171,10 +182,13 @@ michielvk
miguelelgallo
mihi314
mikailmm
minorcell
misairuzame
mischief
mitchellh
miupa
mjbommar
mohshami
molechowski
moonmao42
-morgengeluk Appears to be using AI inappropriately even after it was requested they abide by the AI policy (there is clear evidence of the person-in-the-loop not attempting to clean up AI generated text, and their AI disclosure itself reads like AI-generated text and shows no signs of remorse or intent to improve).
@ -187,8 +201,10 @@ neo773
neurosnap
nicholas-ochoa
nicosuave
nikicat
nmggithub
noib3
nolinmcfarland
nouritsu
nwehg
ocean6954
@ -210,7 +226,9 @@ prakhar54-byte
priyans-hu
puzza007
qwerasd205
raphamorim
reo101
rewdy
rgehan
rhodes-b
rightaditya
@ -230,6 +248,7 @@ sunshine-syz
tbrundige
tdgroot
tdslot
thirstycrow
thoutbeckers
ticclick
tnagatomi
@ -240,6 +259,7 @@ tweedbeetle
uhojin
unphased
uzaaft
vancluever
vaughanandrews
viruslobster
vlsi
@ -248,6 +268,7 @@ wyounas
yabbal
yamshta
ydah
-zaviro
zenyr
zeshi09
zubb

View File

@ -50,7 +50,7 @@ jobs:
uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@ -93,7 +93,7 @@ jobs:
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -147,7 +147,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@ -45,7 +45,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -178,7 +178,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -233,7 +233,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -312,7 +312,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -404,7 +404,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -660,7 +660,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -857,7 +857,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@ -169,7 +169,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -227,7 +227,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -263,7 +263,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -329,7 +329,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -369,7 +369,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -407,7 +407,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -524,7 +524,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -558,7 +558,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -603,7 +603,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -639,7 +639,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -679,7 +679,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -756,7 +756,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -785,7 +785,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -818,7 +818,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -900,7 +900,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -940,7 +940,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1008,7 +1008,7 @@ jobs:
# - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
# with:
# nix_path: nixpkgs=channel:nixos-unstable
# - uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
# - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
# with:
# name: ghostty
# authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1071,7 +1071,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1131,7 +1131,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1185,7 +1185,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1222,7 +1222,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1257,7 +1257,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1305,7 +1305,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1340,7 +1340,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1371,7 +1371,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1430,7 +1430,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1461,7 +1461,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1503,7 +1503,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1534,7 +1534,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1564,7 +1564,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1593,7 +1593,7 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1622,7 +1622,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1650,7 +1650,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1678,7 +1678,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1711,7 +1711,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1739,7 +1739,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1776,7 +1776,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
@ -1808,7 +1808,7 @@ jobs:
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: dist
file: dist/src/build/docker/debian/Dockerfile
@ -1838,7 +1838,7 @@ jobs:
- uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@ -32,7 +32,7 @@ jobs:
uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@1eb2ef646ac0255473d23a5907ad7b04ce94065c # v17
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"

View File

@ -8,7 +8,7 @@ jobs:
check:
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}
@ -18,10 +18,11 @@ jobs:
with:
sparse-checkout: .github/issue-unvouched-message
- uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
- uses: mitchellh/vouch/action/check-issue@52aec3d64655edf2fdb58f298e02da754a056daf # unreleased main
with:
issue-number: ${{ github.event.issue.number }}
auto-close: true
auto-lock: true
template-file: .github/issue-unvouched-message
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}

View File

@ -8,7 +8,7 @@ jobs:
check:
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}

View File

@ -12,7 +12,7 @@ jobs:
manage:
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}

View File

@ -12,7 +12,7 @@ jobs:
manage:
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}

View File

@ -13,7 +13,7 @@ jobs:
sync:
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ secrets.VOUCH_APP_ID }}

1
CLAUDE.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -172,6 +172,7 @@
/po/es_AR.po @ghostty-org/es_AR
/po/es_BO.po @ghostty-org/es_BO
/po/es_ES.po @ghostty-org/es_ES
/po/eu.po @ghostty-org/eu_ES
/po/fr.po @ghostty-org/fr_FR
/po/ga.po @ghostty-org/ga_IE
/po/he.po @ghostty-org/he_IL

View File

@ -116,8 +116,8 @@
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.android_ndk = .{ .path = "./pkg/android-ndk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz",
.hash = "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz",
.hash = "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@ -54,10 +54,10 @@
"url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ": {
"N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz",
"hash": "sha256-3iY7YiCQrhLGcH1nVNozirX1DW9/WyRNaJCElJzcKwU="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz",
"hash": "sha256-HGujRdQdWtVIf3GwCgQgjV9lbwWxJSIDJOWq3gOX3kU="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@ -193,11 +193,11 @@ in
};
}
{
name = "N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ";
name = "N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz";
hash = "sha256-3iY7YiCQrhLGcH1nVNozirX1DW9/WyRNaJCElJzcKwU=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz";
hash = "sha256-HGujRdQdWtVIf3GwCgQgjV9lbwWxJSIDJOWq3gOX3kU=";
unpack = false;
};
}

2
build.zig.zon.txt generated
View File

@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz

View File

@ -0,0 +1,19 @@
# Example: `ghostty-vt` Tracked Grid References
This contains a simple example of how to use the `ghostty-vt` terminal and
tracked grid reference APIs to keep a long-lived reference to a cell as the
terminal scrolls, detect when that reference loses its meaningful location,
and move the same tracked handle to a new point.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source
tree, but Ghostty emits a standard C library that can be used with any
C tooling.
## Usage
Run the program:
```shell-session
zig build run
```

View File

@ -0,0 +1,42 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the app");
const exe_mod = b.createModule(.{
.target = target,
.optimize = optimize,
});
exe_mod.addCSourceFiles(.{
.root = b.path("src"),
.files = &.{"main.c"},
});
// You'll want to use a lazy dependency here so that ghostty is only
// downloaded if you actually need it.
if (b.lazyDependency("ghostty", .{
// Setting simd to false will force a pure static build that
// doesn't even require libc, but it has a significant performance
// penalty. If your embedding app requires libc anyway, you should
// always keep simd enabled.
// .simd = false,
})) |dep| {
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
}
// Exe
const exe = b.addExecutable(.{
.name = "c_vt_grid_ref_tracked",
.root_module = exe_mod,
});
b.installArtifact(exe);
// Run
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_step.dependOn(&run_cmd.step);
}

View File

@ -0,0 +1,24 @@
.{
.name = .c_vt_grid_ref_tracked,
.version = "0.0.0",
.fingerprint = 0x64bd14b59e76c294,
.minimum_zig_version = "0.15.1",
.dependencies = .{
// Ghostty dependency. In reality, you'd probably use a URL-based
// dependency like the one showed (and commented out) below this one.
// We use a path dependency here for simplicity and to ensure our
// examples always test against the source they're bundled with.
.ghostty = .{ .path = "../../" },
// Example of what a URL-based dependency looks like:
// .ghostty = .{
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
// },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@ -0,0 +1,94 @@
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [grid-ref-tracked]
static uint32_t codepoint_at_tracked_ref(GhosttyTrackedGridRef tracked) {
GhosttyGridRef snapshot = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyResult result = ghostty_tracked_grid_ref_snapshot(tracked, &snapshot);
assert(result == GHOSTTY_SUCCESS);
GhosttyCell cell;
result = ghostty_grid_ref_cell(&snapshot, &cell);
assert(result == GHOSTTY_SUCCESS);
bool has_text = false;
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_HAS_TEXT, &has_text);
assert(has_text);
uint32_t codepoint = 0;
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &codepoint);
return codepoint;
}
int main() {
GhosttyTerminal terminal;
GhosttyTerminalOptions opts = {
.cols = 8,
.rows = 3,
.max_scrollback = 100,
};
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
assert(result == GHOSTTY_SUCCESS);
const char *text = "alpha\r\n"
"bravo\r\n"
"charlie";
ghostty_terminal_vt_write(
terminal, (const uint8_t *)text, strlen(text));
GhosttyTrackedGridRef tracked = NULL;
GhosttyPoint alpha = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 0, .y = 0 } },
};
result = ghostty_terminal_grid_ref_track(terminal, alpha, &tracked);
assert(result == GHOSTTY_SUCCESS);
// Writing another line scrolls the original "alpha" row into scrollback.
// The tracked ref still follows the same cell.
const char *more = "\r\ndelta";
ghostty_terminal_vt_write(
terminal, (const uint8_t *)more, strlen(more));
assert(ghostty_tracked_grid_ref_has_value(tracked));
printf("tracked codepoint after scroll: %c\n",
(char)codepoint_at_tracked_ref(tracked));
GhosttyPointCoordinate screen = {0};
result = ghostty_tracked_grid_ref_point(
tracked, GHOSTTY_POINT_TAG_SCREEN, &screen);
assert(result == GHOSTTY_SUCCESS);
printf("tracked screen point: %u,%u\n", screen.x, screen.y);
// Resetting the terminal discards the old grid contents. The tracked
// handle remains valid, but no longer has a meaningful location.
ghostty_terminal_reset(terminal);
assert(!ghostty_tracked_grid_ref_has_value(tracked));
GhosttyGridRef discarded = GHOSTTY_INIT_SIZED(GhosttyGridRef);
result = ghostty_tracked_grid_ref_snapshot(tracked, &discarded);
assert(result == GHOSTTY_NO_VALUE);
// The same handle can be moved to a new point after it loses its value.
const char *replacement = "echo";
ghostty_terminal_vt_write(
terminal, (const uint8_t *)replacement, strlen(replacement));
GhosttyPoint echo = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 0, .y = 0 } },
};
result = ghostty_tracked_grid_ref_set(tracked, terminal, echo);
assert(result == GHOSTTY_SUCCESS);
assert(ghostty_tracked_grid_ref_has_value(tracked));
printf("tracked codepoint after reset/set: %c\n",
(char)codepoint_at_tracked_ref(tracked));
ghostty_tracked_grid_ref_free(tracked);
ghostty_terminal_free(terminal);
return 0;
}
//! [grid-ref-tracked]

View File

@ -2,8 +2,8 @@
This contains an example of how to use the `ghostty-vt` render-state API
to create a render state, update it from terminal content, iterate rows
and cells, read styles and colors, inspect cursor state, and manage dirty
tracking.
and cells, read styles and colors, inspect cursor and row-local selection
state, and manage dirty tracking.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source

View File

@ -46,6 +46,32 @@ int main(void) {
ghostty_terminal_vt_write(
terminal, (const uint8_t*)content, strlen(content));
// Select "underlined" on the second row. Render state exposes this
// later as a row-local selected cell range.
GhosttyGridRef selection_start = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint selection_start_pt = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 0, .y = 1 } },
};
result = ghostty_terminal_grid_ref(
terminal, selection_start_pt, &selection_start);
assert(result == GHOSTTY_SUCCESS);
GhosttyGridRef selection_end = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint selection_end_pt = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = 9, .y = 1 } },
};
result = ghostty_terminal_grid_ref(terminal, selection_end_pt, &selection_end);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
selection.start = selection_start;
selection.end = selection_end;
result = ghostty_terminal_set(
terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_render_state_update(render_state, terminal);
assert(result == GHOSTTY_SUCCESS);
//! [render-state-update]
@ -154,6 +180,18 @@ int main(void) {
printf("Row %2d [%s]: ", row_index,
row_dirty ? "dirty" : "clean");
// Query the row-local selection range. Rows without a selection return
// GHOSTTY_NO_VALUE; selected rows return inclusive start/end columns.
GhosttyRenderStateRowSelection row_selection =
GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection);
result = ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION, &row_selection);
assert(result == GHOSTTY_SUCCESS || result == GHOSTTY_NO_VALUE);
if (result == GHOSTTY_SUCCESS) {
printf("selection=%u..%u ",
row_selection.start_x, row_selection.end_x);
}
// Get cells for this row (reuses the same cells handle).
result = ghostty_render_state_row_get(
row_iter, GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, &cells);

View File

@ -0,0 +1,18 @@
# Example: `ghostty-vt` Selection Gestures
This contains a simple example of how to use the `ghostty-vt` selection
gesture API from C. It creates synthetic press, drag, release, and deep-press
events and formats the resulting selection snapshots.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source
tree, but Ghostty emits a standard C library that can be used with any
C tooling.
## Usage
Run the program:
```shell-session
zig build run
```

View File

@ -0,0 +1,42 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the app");
const exe_mod = b.createModule(.{
.target = target,
.optimize = optimize,
});
exe_mod.addCSourceFiles(.{
.root = b.path("src"),
.files = &.{"main.c"},
});
// You'll want to use a lazy dependency here so that ghostty is only
// downloaded if you actually need it.
if (b.lazyDependency("ghostty", .{
// Setting simd to false will force a pure static build that
// doesn't even require libc, but it has a significant performance
// penalty. If your embedding app requires libc anyway, you should
// always keep simd enabled.
// .simd = false,
})) |dep| {
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
}
// Exe
const exe = b.addExecutable(.{
.name = "c_vt_selection_gesture",
.root_module = exe_mod,
});
b.installArtifact(exe);
// Run
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_step.dependOn(&run_cmd.step);
}

View File

@ -0,0 +1,24 @@
.{
.name = .c_vt_selection_gesture,
.version = "0.0.0",
.fingerprint = 0x5a4e72d27b582404,
.minimum_zig_version = "0.15.1",
.dependencies = .{
// Ghostty dependency. In reality, you'd probably use a URL-based
// dependency like the one showed (and commented out) below this one.
// We use a path dependency here for simplicity and to ensure our
// examples always test against the source they're bundled with.
.ghostty = .{ .path = "../../" },
// Example of what a URL-based dependency looks like:
// .ghostty = .{
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
// },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@ -0,0 +1,162 @@
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [selection-gesture-main]
static void vt_write(GhosttyTerminal terminal, const char *s) {
ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s));
}
static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) {
GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint point = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = x, .y = y } },
};
GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref);
assert(result == GHOSTTY_SUCCESS);
return ref;
}
static void print_selection(
GhosttyTerminal terminal,
const char *label,
const GhosttySelection *selection) {
GhosttyTerminalSelectionFormatOptions opts =
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectionFormatOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.trim = true;
opts.selection = selection;
uint8_t *buf = NULL;
size_t len = 0;
GhosttyResult result = ghostty_terminal_selection_format_alloc(
terminal, NULL, opts, &buf, &len);
assert(result == GHOSTTY_SUCCESS);
printf("%s: ", label);
fwrite(buf, 1, len, stdout);
printf("\n");
ghostty_free(NULL, buf, len);
}
static GhosttySelectionGestureEvent new_event(
GhosttySelectionGestureEventType type) {
GhosttySelectionGestureEvent event = NULL;
GhosttyResult result = ghostty_selection_gesture_event_new(NULL, &event, type);
assert(result == GHOSTTY_SUCCESS);
return event;
}
int main() {
GhosttyTerminal terminal;
GhosttyTerminalOptions opts = {
.cols = 20,
.rows = 4,
.max_scrollback = 100,
};
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
assert(result == GHOSTTY_SUCCESS);
vt_write(terminal, "hello world\r\nsecond line");
GhosttySelectionGesture gesture = NULL;
result = ghostty_selection_gesture_new(NULL, &gesture);
assert(result == GHOSTTY_SUCCESS);
GhosttySelectionGestureEvent press =
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS);
GhosttySelectionGestureEvent drag =
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG);
GhosttySelectionGestureEvent release =
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE);
GhosttySelectionGestureEvent deep_press =
new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS);
GhosttySelectionGestureGeometry geometry = {
.columns = 20,
.cell_width = 10,
.padding_left = 0,
.screen_height = 40,
};
// Press in the first cell. A normal single press records the click anchor but
// doesn't produce a selection yet, so we discard the optional output.
GhosttyGridRef press_ref = ref_at(terminal, 0, 0);
result = ghostty_selection_gesture_event_set(
press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &press_ref);
assert(result == GHOSTTY_SUCCESS);
GhosttySurfacePosition press_pos = { .x = 2, .y = 8 };
result = ghostty_selection_gesture_event_set(
press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &press_pos);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_selection_gesture_event(
gesture, terminal, press, NULL);
assert(result == GHOSTTY_NO_VALUE);
// Drag across "hello". The drag event returns a selection snapshot that the
// embedder can apply to its UI, copy, or format immediately.
GhosttyGridRef drag_ref = ref_at(terminal, 4, 0);
result = ghostty_selection_gesture_event_set(
drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref);
assert(result == GHOSTTY_SUCCESS);
GhosttySurfacePosition drag_pos = { .x = 46, .y = 8 };
result = ghostty_selection_gesture_event_set(
drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &drag_pos);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_selection_gesture_event_set(
drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY, &geometry);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
result = ghostty_selection_gesture_event(
gesture, terminal, drag, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "drag", &selection);
// Release updates gesture state but never produces a selection.
result = ghostty_selection_gesture_event_set(
release, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_selection_gesture_event(
gesture, terminal, release, NULL);
assert(result == GHOSTTY_NO_VALUE);
bool dragged = false;
result = ghostty_selection_gesture_get(
gesture, terminal, GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED, &dragged);
assert(result == GHOSTTY_SUCCESS);
printf("dragged: %s\n", dragged ? "true" : "false");
// Deep press uses the active click anchor to select the surrounding word.
ghostty_selection_gesture_reset(gesture, terminal);
GhosttyGridRef world_ref = ref_at(terminal, 6, 0);
result = ghostty_selection_gesture_event_set(
press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &world_ref);
assert(result == GHOSTTY_SUCCESS);
result = ghostty_selection_gesture_event(
gesture, terminal, press, NULL);
assert(result == GHOSTTY_NO_VALUE);
result = ghostty_selection_gesture_event(
gesture, terminal, deep_press, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "deep press", &selection);
ghostty_selection_gesture_event_free(deep_press);
ghostty_selection_gesture_event_free(release);
ghostty_selection_gesture_event_free(drag);
ghostty_selection_gesture_event_free(press);
ghostty_selection_gesture_free(gesture, terminal);
ghostty_terminal_free(terminal);
return 0;
}
//! [selection-gesture-main]

View File

@ -0,0 +1,18 @@
# Example: `ghostty-vt` Selection
This contains a simple example of how to use the `ghostty-vt` terminal,
grid reference, selection, and formatter APIs to derive selections such as a
word, semantic command line, command output, and all visible content.
This uses a `build.zig` and `Zig` to build the C program so that we
can reuse a lot of our build logic and depend directly on our source
tree, but Ghostty emits a standard C library that can be used with any
C tooling.
## Usage
Run the program:
```shell-session
zig build run
```

View File

@ -0,0 +1,42 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the app");
const exe_mod = b.createModule(.{
.target = target,
.optimize = optimize,
});
exe_mod.addCSourceFiles(.{
.root = b.path("src"),
.files = &.{"main.c"},
});
// You'll want to use a lazy dependency here so that ghostty is only
// downloaded if you actually need it.
if (b.lazyDependency("ghostty", .{
// Setting simd to false will force a pure static build that
// doesn't even require libc, but it has a significant performance
// penalty. If your embedding app requires libc anyway, you should
// always keep simd enabled.
// .simd = false,
})) |dep| {
exe_mod.linkLibrary(dep.artifact("ghostty-vt"));
}
// Exe
const exe = b.addExecutable(.{
.name = "c_vt_selection",
.root_module = exe_mod,
});
b.installArtifact(exe);
// Run
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_step.dependOn(&run_cmd.step);
}

View File

@ -0,0 +1,24 @@
.{
.name = .c_vt_selection,
.version = "0.0.0",
.fingerprint = 0xb2c2f1a828086fef,
.minimum_zig_version = "0.15.1",
.dependencies = .{
// Ghostty dependency. In reality, you'd probably use a URL-based
// dependency like the one showed (and commented out) below this one.
// We use a path dependency here for simplicity and to ensure our
// examples always test against the source they're bundled with.
.ghostty = .{ .path = "../../" },
// Example of what a URL-based dependency looks like:
// .ghostty = .{
// .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz",
// .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s",
// },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@ -0,0 +1,136 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [selection-main]
static void vt_write(GhosttyTerminal terminal, const char *s) {
ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s));
}
static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) {
GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef);
GhosttyPoint point = {
.tag = GHOSTTY_POINT_TAG_ACTIVE,
.value = { .coordinate = { .x = x, .y = y } },
};
GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref);
assert(result == GHOSTTY_SUCCESS);
return ref;
}
static void print_selection(
GhosttyTerminal terminal,
const char *label,
const GhosttySelection *selection) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
opts.trim = true;
opts.selection = selection;
GhosttyFormatter formatter;
GhosttyResult result = ghostty_formatter_terminal_new(
NULL, &formatter, terminal, opts);
assert(result == GHOSTTY_SUCCESS);
uint8_t *buf = NULL;
size_t len = 0;
result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len);
assert(result == GHOSTTY_SUCCESS);
printf("%s: ", label);
fwrite(buf, 1, len, stdout);
printf("\n");
ghostty_free(NULL, buf, len);
ghostty_formatter_free(formatter);
}
int main() {
GhosttyTerminal terminal;
GhosttyTerminalOptions opts = {
.cols = 80,
.rows = 8,
.max_scrollback = 0,
};
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
assert(result == GHOSTTY_SUCCESS);
// A realistic shell transcript with OSC 133 semantic prompt markers.
// Ghostty uses these markers to distinguish prompt/input from command
// output for semantic line and output selections.
vt_write(terminal,
"\033]133;A\007$ " // Prompt starts: "$ "
"\033]133;B\007git status" // Input starts: "git status"
"\033]133;C\007\r\n" // Output starts after Enter
"On branch main\r\n"
"nothing to commit, working tree clean");
GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection);
// Double-click style word selection under the cursor.
GhosttyTerminalSelectWordOptions word = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordOptions);
word.ref = ref_at(terminal, 6, 0); // the "status" in "git status"
result = ghostty_terminal_select_word(terminal, &word, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "word", &selection);
//! [selection-word-between]
// Double-click-and-drag style selection. Suppose the user double-clicks
// "git" and drags to "status". The pointer may pass over whitespace, so
// select the nearest word between the original click and current drag point
// in both directions, then combine the outer word bounds.
GhosttyGridRef click_ref = ref_at(terminal, 2, 0); // the "git" in "git status"
GhosttyGridRef drag_ref = ref_at(terminal, 6, 0); // the "status" in "git status"
GhosttyTerminalSelectWordBetweenOptions start_word_opts =
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions);
start_word_opts.start = click_ref;
start_word_opts.end = drag_ref;
GhosttySelection start_word = GHOSTTY_INIT_SIZED(GhosttySelection);
result = ghostty_terminal_select_word_between(
terminal, &start_word_opts, &start_word);
assert(result == GHOSTTY_SUCCESS);
GhosttyTerminalSelectWordBetweenOptions end_word_opts =
GHOSTTY_INIT_SIZED(GhosttyTerminalSelectWordBetweenOptions);
end_word_opts.start = drag_ref;
end_word_opts.end = click_ref;
GhosttySelection end_word = GHOSTTY_INIT_SIZED(GhosttySelection);
result = ghostty_terminal_select_word_between(
terminal, &end_word_opts, &end_word);
assert(result == GHOSTTY_SUCCESS);
GhosttySelection drag_selection = GHOSTTY_INIT_SIZED(GhosttySelection);
drag_selection.start = start_word.start;
drag_selection.end = end_word.end;
print_selection(terminal, "double-click drag", &drag_selection);
//! [selection-word-between]
// Triple-click style line selection. With semantic prompt boundaries enabled,
// this selects only the input area rather than the leading "$ " prompt.
GhosttyTerminalSelectLineOptions line = GHOSTTY_INIT_SIZED(GhosttyTerminalSelectLineOptions);
line.ref = ref_at(terminal, 2, 0); // the "git status" input area
line.semantic_prompt_boundary = true;
result = ghostty_terminal_select_line(terminal, &line, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "line", &selection);
// Select exactly the command output for the command under the cursor.
result = ghostty_terminal_select_output(
terminal, ref_at(terminal, 0, 1), &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "output", &selection);
// Select all visible content.
result = ghostty_terminal_select_all(terminal, &selection);
assert(result == GHOSTTY_SUCCESS);
print_selection(terminal, "all", &selection);
ghostty_terminal_free(terminal);
return 0;
}
//! [selection-main]

View File

@ -67,9 +67,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260427-153600-5e4d1de.tgz",
"dest": "vendor/p/N-V-__8AAG6jAwDWij8XfaQ0fy-HAQqvl1b6kZb4GfbHjbkZ",
"sha256": "de263b622090ae12c6707d6754da338ab5f50d6f7f5b244d689084949cdc2b05"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260525-155808-7335c0a.tgz",
"dest": "vendor/p/N-V-__8AAGi9AwC7QV7hLqjN6iBkXA2y5dxw285nkSLlVB7I",
"sha256": "1c6ba345d41d5ad5487f71b00a04208d5f656f05b125220324e5aade0397de45"
},
{
"type": "archive",

View File

@ -1054,6 +1054,7 @@ typedef union {
// apprt.ipc.Action.Key
typedef enum {
GHOSTTY_IPC_ACTION_NEW_WINDOW,
GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL,
} ghostty_ipc_action_tag_e;
//-------------------------------------------------------------------

View File

@ -54,6 +54,7 @@
* - @ref c-vt-sgr/src/main.c - SGR parser example
* - @ref c-vt-formatter/src/main.c - Terminal formatter example
* - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs
* - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example
*
*/
@ -98,6 +99,16 @@
* grid refs to inspect cell codepoints, row wrap state, and cell styles.
*/
/** @example c-vt-grid-ref-tracked/src/main.c
* This example demonstrates how to track a grid ref as the terminal scrolls,
* detect when it loses its value, and move it to a new point.
*/
/** @example c-vt-selection-gesture/src/main.c
* This example demonstrates how to use synthetic selection gesture events to
* derive drag and deep-press selection snapshots.
*/
/** @example c-vt-kitty-graphics/src/main.c
* This example demonstrates how to use the system interface to install a
* PNG decoder callback and send a Kitty Graphics Protocol image.
@ -120,6 +131,7 @@ extern "C" {
#include <ghostty/vt/render.h>
#include <ghostty/vt/terminal.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/grid_ref_tracked.h>
#include <ghostty/vt/osc.h>
#include <ghostty/vt/sgr.h>
#include <ghostty/vt/style.h>
@ -129,6 +141,7 @@ extern "C" {
#include <ghostty/vt/modes.h>
#include <ghostty/vt/mouse.h>
#include <ghostty/vt/paste.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/size_report.h>

View File

@ -32,23 +32,6 @@ extern "C" {
* @{
*/
/**
* Output format.
*
* @ingroup formatter
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Plain text (no escape sequences). */
GHOSTTY_FORMATTER_FORMAT_PLAIN,
/** VT sequences preserving colors, styles, URLs, etc. */
GHOSTTY_FORMATTER_FORMAT_VT,
/** HTML with inline styles. */
GHOSTTY_FORMATTER_FORMAT_HTML,
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyFormatterFormat;
/**
* Extra screen state to include in styled output.
*

View File

@ -20,24 +20,79 @@ extern "C" {
/** @defgroup grid_ref Grid Reference
*
* A grid reference is a resolved reference to a specific cell position in the
* terminal's internal page structure. Obtain a grid reference from
* ghostty_terminal_grid_ref(), then extract the cell or row via
* ghostty_grid_ref_cell() and ghostty_grid_ref_row().
* A grid reference is a reference to a specific cell position in the
* terminal. Obtain a grid reference from `ghostty_terminal_grid_ref`
* for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked
* vs tracked is explained next.
*
* A grid reference is only valid until the next update to the terminal
* instance. There is no guarantee that a grid reference will remain
* valid after ANY operation, even if a seemingly unrelated part of
* the grid is changed, so any information related to the grid reference
* should be read and cached immediately after obtaining the grid reference.
* Important: The grid reference APIs are not meant to be used as the core of a render
* loop. They are not built to sustain the framerates needed for rendering large
* screens. Use the render state API for that.
*
* This API is not meant to be used as the core of render loop. It isn't
* built to sustain the framerates needed for rendering large screens.
* Use the render state API for that.
* ## Untracked vs Tracked References
*
* ### Untracked Reference
*
* ## Example
* An untracked grid reference is a value type that snapshots a specific
* cell. It is only valid until the next update to the terminal instance.
* There is no guarantee that it will remain valid after any operation,
* even if a seemingly unrelated part of the grid is changed. These are meant
* to be read and have their values cached immediately after obtaining it.
*
* An untracked grid reference has a performance cost in its initial lookup,
* but doesn't affect the ongoing performance of the terminal in any way,
* since it is a one-time snapshot.
*
* ### Tracked Reference
*
* A tracked grid reference follows its cell across normal screen operations.
* For example scrolling, scrollback pruning, resize/reflow, and other
* terminal mutations update the tracked reference automatically.
*
* A tracked reference can still lose its original semantic location. This can
* happen when the underlying grid is reset, pruned, or otherwise discarded in a
* way that cannot be mapped to a meaningful new cell. In that state,
* ghostty_tracked_grid_ref_has_value() returns false and
* ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return
* GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new
* point with ghostty_tracked_grid_ref_set().
*
* To read cell data from a tracked reference, first snapshot it with
* ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again
* an untracked reference and follows the same short lifetime rules as any other
* untracked grid reference.
*
* A tracked reference belongs to the terminal screen/page-list that was active
* when it was created or last set. Converting it to a point uses that owning
* screen/page-list, even if the terminal has since switched between primary and
* alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new
* point against the terminal's currently active screen/page-list and may move
* the tracked reference between screens.
*
* Tracked references are owned by the caller and must be freed with
* ghostty_tracked_grid_ref_free(). If the terminal that created a tracked
* reference is freed first, the handle remains valid only for tracked-grid-ref
* APIs: it reports no value and can still be freed.
*
* Each tracked reference adds bookkeeping to terminal mutations. Use them
* sparingly for long-lived anchors such as selections, search state, marks,
* or application-side bookmarks.
*
* ## Lifetime
*
* An untracked reference is a snapshot. It doesn't need to be freed.
* The safety of accessing the value is documented explicitly above: it
* is only safe to access any data until the next terminal mutating
* operation (including free).
*
* A tracked reference is allocated and must be freed when it is no
* longer needed. A tracked reference may outlive the terminal that created it;
* after terminal free, it reports no value and can still be freed.
*
* ## Examples
*
* @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse
* @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked
*
* @{
*/

View File

@ -0,0 +1,139 @@
/**
* @file grid_ref_tracked.h
*
* Tracked terminal grid references.
*/
#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H
#define GHOSTTY_VT_GRID_REF_TRACKED_H
#include <stdbool.h>
#include <ghostty/vt/types.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/point.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* Tracked grid references are owned grid references that move with the
* terminal. See @ref grid_ref for the full overview of tracked and untracked
* grid reference behavior.
*
* @ingroup grid_ref
*/
/**
* Free a tracked grid reference.
*
* Passing NULL is allowed and has no effect. A tracked reference may be freed
* after the terminal that created it is freed.
*
* @param ref Tracked grid reference to free.
*
* @ingroup grid_ref
*/
GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref);
/**
* Return whether a tracked grid reference currently has a meaningful value.
*
* If the terminal that created the tracked reference has been freed, this
* returns false.
*
* @param ref Tracked grid reference.
* @return true if the reference currently has a meaningful value.
*
* @ingroup grid_ref
*/
GHOSTTY_API bool ghostty_tracked_grid_ref_has_value(
GhosttyTrackedGridRef ref);
/**
* Convert a tracked grid reference to a point in the requested coordinate
* space.
*
* This is the tracked equivalent of ghostty_terminal_point_from_grid_ref().
* Unlike snapshotting, this does not expose an intermediate untracked
* GhosttyGridRef.
*
* A tracked reference is resolved against the terminal screen/page-list that
* currently owns the reference. If the terminal has switched between primary
* and alternate screens since the reference was created or last set, this may
* be different from the terminal's currently active screen.
*
* If the tracked reference no longer has a meaningful value, this returns
* GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot
* be represented in the requested coordinate space, including after the
* terminal that created the tracked reference has been freed.
*
* @param ref Tracked grid reference.
* @param tag Coordinate space to convert into.
* @param[out] out_point On success, receives the coordinate. May be NULL.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,
* or GHOSTTY_NO_VALUE if there is no representable value.
*
* @ingroup grid_ref
*/
GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point(
GhosttyTrackedGridRef ref,
GhosttyPointTag tag,
GhosttyPointCoordinate *out_point);
/**
* Move an existing tracked grid reference to a new terminal point.
*
* On success, the tracked reference begins tracking the new point and any prior
* "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked
* reference is left unchanged.
*
* The terminal must be the same terminal that created the tracked reference.
* The point is resolved against the terminal screen/page-list that is active at
* the time this function is called. If the terminal has switched between
* primary and alternate screens, this may move the tracked reference from one
* screen/page-list to the other.
*
* @param ref Tracked grid reference.
* @param terminal Terminal instance that owns the reference.
* @param point New point to track.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal,
* or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails.
*
* @ingroup grid_ref
*/
GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set(
GhosttyTrackedGridRef ref,
GhosttyTerminal terminal,
GhosttyPoint point);
/**
* Snapshot a tracked grid reference into a regular GhosttyGridRef.
*
* The returned GhosttyGridRef is an untracked snapshot and has the same
* lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the
* next terminal update. Snapshot immediately before calling
* ghostty_grid_ref_cell(), ghostty_grid_ref_row(),
* ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or
* ghostty_grid_ref_style().
*
* If the tracked reference no longer has a meaningful value, this returns
* GHOSTTY_NO_VALUE. This includes references whose owning terminal has been
* freed.
*
* @param ref Tracked grid reference.
* @param[out] out_ref On success, receives an untracked snapshot. May be NULL.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid,
* or GHOSTTY_NO_VALUE if the tracked location was discarded.
*
* @ingroup grid_ref
*/
GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot(
GhosttyTrackedGridRef ref,
GhosttyGridRef *out_ref);
#ifdef __cplusplus
}
#endif
#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */

View File

@ -221,6 +221,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
* valid as long as the underlying render state is not updated.
* It is unsafe to use cell data after updating the render state. */
GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3,
/** Row-local selected cell range (GhosttyRenderStateRowSelection). */
GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4,
GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowData;
@ -235,6 +238,29 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowOption;
/**
* Row-local selection range.
*
* This struct uses the sized-struct ABI pattern. Initialize with
* GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying
* GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION.
*
* Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE
* if the current row does not intersect the current selection.
*
* @ingroup render
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */
size_t size;
/** Start column of the row-local selection range, inclusive. */
uint16_t start_x;
/** End column of the row-local selection range, inclusive. */
uint16_t end_x;
} GhosttyRenderStateRowSelection;
/**
* Render-state color information.
*
@ -571,6 +597,36 @@ typedef enum GHOSTTY_ENUM_TYPED {
* color, in which case the caller should use whatever default foreground
* color it wants (e.g. the terminal foreground). */
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6,
/** Whether the cell is contained within the current selection (bool).
* This returns true when the cell's column is within the current row's
* row-local selection range, and false otherwise. Rendering policy for
* selected cells (colors, inversion, etc.) is left to the caller.
*
* Renderers that can draw cells in spans may be more efficient querying
* GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that
* range directly, avoiding one C API call per cell for selection state. */
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7,
/** Whether the cell has any explicit styling (bool).
* This is equivalent to querying the raw cell's
* GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw
* GhosttyCell for renderers that only need to know whether fetching the
* full style is necessary. */
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8,
/**
* Encode the current cell's full grapheme cluster as UTF-8 into a
* caller-provided buffer (GhosttyBuffer).
*
* The base codepoint is encoded first, followed by any extra grapheme
* codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text.
*
* If ptr is NULL or cap is too small for a non-empty cell, returns
* GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required
* buffer size in bytes.
*/
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9,
GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyRenderStateRowCellsData;

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
#include <ghostty/vt/kitty_graphics.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/point.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/style.h>
#ifdef __cplusplus
@ -592,6 +593,21 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20,
/**
* Set the active screen selection.
*
* The value must point to a GhosttySelection whose grid references are
* valid for this terminal's active screen at the time of the call. The
* terminal copies the selection immediately and converts it to
* terminal-owned tracked state, so the GhosttySelection struct and its
* untracked grid references do not need to outlive this call.
*
* Passing NULL clears the active screen selection.
*
* Input type: GhosttySelection*
*/
GHOSTTY_TERMINAL_OPT_SELECTION = 21,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
@ -868,6 +884,33 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Output type: GhosttyKittyGraphics *
*/
GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30,
/**
* The active screen's current selection.
*
* On success, writes an untracked snapshot of the terminal-owned selection
* to the caller-provided GhosttySelection. The GhosttySelection struct is
* caller-owned and may be kept, but the grid references inside it are
* untracked borrowed references into the active screen. They are only valid
* until the next mutating terminal call, such as ghostty_terminal_set(),
* ghostty_terminal_vt_write(), ghostty_terminal_resize(), or
* ghostty_terminal_reset().
*
* Returns GHOSTTY_NO_VALUE when there is no active selection.
*
* Output type: GhosttySelection *
*/
GHOSTTY_TERMINAL_DATA_SELECTION = 31,
/**
* Whether the viewport is currently pinned to the active area.
*
* This is true when the viewport is following the active terminal area,
* and false when the user has scrolled into history.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32,
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalData;
@ -1120,6 +1163,38 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal,
GhosttyPoint point,
GhosttyGridRef *out_ref);
/**
* Create an owned tracked grid reference for a terminal point.
*
* This is the tracked variant of ghostty_terminal_grid_ref(). The returned
* handle follows the referenced cell as the terminal's page list is modified:
* scrolling, pruning, resize/reflow, and other page-list operations update the
* tracked reference automatically.
*
* The reference is attached to the terminal screen/page-list that is active at
* creation time.
*
* If the point is outside the requested coordinate space, this returns
* GHOSTTY_INVALID_VALUE and writes NULL to out_ref.
*
* The returned handle must be freed with ghostty_tracked_grid_ref_free(). If
* the terminal is freed first, the handle remains valid only for
* tracked-grid-ref APIs: it reports no value and can still be freed.
*
* @param terminal Terminal instance.
* @param point Point to track.
* @param[out] out_ref On success, receives the tracked reference handle.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal,
* point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation
* fails.
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track(
GhosttyTerminal terminal,
GhosttyPoint point,
GhosttyTrackedGridRef *out_ref);
/**
* Convert a grid reference back to a point in the given coordinate system.
*

View File

@ -94,6 +94,18 @@ typedef enum GHOSTTY_ENUM_TYPED {
*/
typedef struct GhosttyTerminalImpl* GhosttyTerminal;
/**
* Opaque handle to a tracked grid reference.
*
* A tracked grid reference is owned by the caller and must be freed with
* ghostty_tracked_grid_ref_free(). If the terminal that created it is freed
* first, the handle remains valid only for tracked-grid-ref APIs: it reports no
* value and can still be freed.
*
* @ingroup grid_ref
*/
typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef;
/**
* Opaque handle to a Kitty graphics image storage.
*
@ -184,6 +196,23 @@ typedef struct GhosttyOscCommandImpl* GhosttyOscCommand;
/* ---- Common value types ---- */
/**
* Terminal content output format.
*
* @ingroup formatter
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Plain text (no escape sequences). */
GHOSTTY_FORMATTER_FORMAT_PLAIN,
/** VT sequences preserving colors, styles, URLs, etc. */
GHOSTTY_FORMATTER_FORMAT_VT,
/** HTML with inline styles. */
GHOSTTY_FORMATTER_FORMAT_HTML,
GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyFormatterFormat;
/**
* A borrowed byte string (pointer + length).
*
@ -198,6 +227,55 @@ typedef struct {
size_t len;
} GhosttyString;
/**
* A caller-provided byte buffer.
*
* APIs that write to this type use `len` for the number of bytes written on
* GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE.
*/
typedef struct {
/** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */
uint8_t* ptr;
/** Capacity of ptr in bytes. */
size_t cap;
/** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */
size_t len;
} GhosttyBuffer;
/**
* A surface-space position in pixels.
*
* This is not a terminal grid coordinate. It represents an x/y position in the
* rendered surface coordinate space, with (0, 0) at the top-left of the
* surface.
*/
typedef struct {
/** X position in surface pixels. */
double x;
/** Y position in surface pixels. */
double y;
} GhosttySurfacePosition;
/**
* A borrowed list of Unicode scalar values.
*
* Values are encoded as uint32_t scalar values. The memory is not owned by this
* struct. The pointer is only valid for the lifetime documented by the API that
* consumes or produces it.
*
* APIs may document special handling for NULL + len 0, such as use defaults.
*/
typedef struct {
/** Pointer to Unicode scalar values. */
const uint32_t* ptr;
/** Number of entries in ptr. */
size_t len;
} GhosttyCodepoints;
/**
* Initialize a sized struct to zero and set its size field.
*

View File

@ -109,6 +109,7 @@
Helpers/CrossKit.swift,
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/OSColor+Extension.swift",
"Helpers/Extensions/OSPasteboard+Extension.swift",
);
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
};

View File

@ -404,20 +404,7 @@ class AppDelegate: NSObject,
// If our app says we don't need to confirm, we can exit now.
if !ghostty.needsConfirmQuit { return .terminateNow }
// We have some visible window. Show an app-wide modal to confirm quitting.
let alert = NSAlert()
alert.messageText = "Quit Ghostty?"
alert.informativeText = "All terminal sessions will be terminated."
alert.addButton(withTitle: "Close Ghostty")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
switch alert.runModal() {
case .alertFirstButtonReturn:
return .terminateNow
default:
return .terminateCancel
}
return terminate()
}
func applicationWillTerminate(_ notification: Notification) {
@ -620,7 +607,7 @@ class AppDelegate: NSObject,
// Build our event input and call ghostty
if ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
// The key was used so we want to stop it from going to our Mac app
Ghostty.logger.debug("local key event handled event=\(event)")
Ghostty.logger.debug("local key event handled event=\(event, privacy: .public)")
return nil
}
@ -675,7 +662,7 @@ class AppDelegate: NSObject,
private func requestBadgeAuthorizationAndSet(_ center: UNUserNotificationCenter) {
center.requestAuthorization(options: [.badge]) { granted, error in
if let error = error {
Self.logger.warning("Error requesting badge authorization: \(error)")
Self.logger.warning("Error requesting badge authorization: \(error, privacy: .public)")
return
}
@ -1305,6 +1292,79 @@ extension AppDelegate: NSMenuItemValidation {
}
}
// MARK: - Termination Flow
extension AppDelegate {
func terminate() -> NSApplication.TerminateReply {
let controllersNeedConfirmation = NSApplication.shared.windows
.compactMap { $0.windowController as? BaseTerminalController }
.filter { !$0.windowCanBeClosedWithoutConfirmation() }
guard !controllersNeedConfirmation.isEmpty else {
return .terminateNow
}
if controllersNeedConfirmation.count == 1 {
Task {
let response = await controllersNeedConfirmation[0].confirmCloseAsync(
messageText: "Quit Ghostty?",
informativeText: "The terminal still has a running process. If you quit, the process will be killed.",
confirmButtonTitle: "Terminate",
)
if [.OK, .alertFirstButtonReturn].contains(response) {
await NSApp.reply(toApplicationShouldTerminate: true)
} else {
await NSApp.reply(toApplicationShouldTerminate: false)
}
}
return .terminateLater
} else {
let alert = NSAlert()
alert.messageText = "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?"
alert.informativeText = "If you don't review your windows, any running processes will be terminated"
alert.addButton(withTitle: "Review Windows...")
alert.addButton(withTitle: "Terminate Processes")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
switch alert.runModal() {
case .alertFirstButtonReturn:
reviewWindows(controllersNeedConfirmation)
return .terminateLater
case .alertSecondButtonReturn:
return .terminateNow
default:
return .terminateCancel
}
}
}
private func reviewWindows(_ controllers: [BaseTerminalController]) {
Task {
for controller in controllers {
let response = await controller.confirmCloseAsync(
messageText: "Quit Ghostty?",
informativeText: "The terminal still has a running process. If you quit, the process will be killed.",
confirmButtonTitle: "Terminate",
)
if [.OK, .alertFirstButtonReturn].contains(response) {
// Close this window and until next review is cancelled
await controller.window?.close()
continue
} else {
await NSApp.reply(toApplicationShouldTerminate: false)
// Cancel the review
return
}
}
await NSApp.reply(toApplicationShouldTerminate: true)
}
}
}
/// Represents the state of the quick terminal controller.
private enum QuickTerminalState {
/// Controller has not been initialized and has no pending restoration state.

View File

@ -13,6 +13,7 @@ class AboutController: NSWindowController, NSWindowDelegate {
window.center()
window.isMovableByWindowBackground = true
window.contentView = NSHostingView(rootView: AboutView().environmentObject(viewModel))
window.titlebarAppearsTransparent = true
}
// MARK: - Functions

View File

@ -16,7 +16,7 @@ class GlobalEventTap {
// The event tap used for global event listening. This is non-nil if it is
// created.
private var eventTap: CFMachPort?
fileprivate var eventTap: CFMachPort?
// This is the timer used to retry enabling the global event tap if we
// don't have permissions.
@ -125,6 +125,17 @@ private func cgEventFlagsChangedHandler(
) -> Unmanaged<CGEvent>? {
let result = Unmanaged.passUnretained(cgEvent)
// macOS disables the event tap if the callback is too slow or for other
// internal reasons. When that happens it sends this event type. We need
// to re-enable the tap or it stays dead forever.
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
GlobalEventTap.logger.warning("global event tap was disabled by the system, re-enabling")
if let machPort = GlobalEventTap.shared.eventTap {
CGEvent.tapEnable(tap: machPort, enable: true)
}
return result
}
// We only care about keydown events
guard type == .keyDown else { return result }
@ -143,7 +154,7 @@ private func cgEventFlagsChangedHandler(
// Build our event input and call ghostty
let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
if ghostty_app_key(ghostty, key_ev) {
GlobalEventTap.logger.info("global key event handled event=\(event)")
GlobalEventTap.logger.info("global key event handled event=\(event, privacy: .public)")
return nil
}

View File

@ -44,16 +44,6 @@ extension QuickTerminalRestorableState {
let focusedSurface: String?
let surfaceTree: SplitTree<ViewType>
let screenStateEntries: QuickTerminalScreenStateCache.Entries
init(
focusedSurface: String?,
surfaceTree: SplitTree<ViewType>,
screenStateEntries: QuickTerminalScreenStateCache.Entries,
) {
self.focusedSurface = focusedSurface
self.surfaceTree = surfaceTree
self.screenStateEntries = screenStateEntries
}
}
}

View File

@ -97,11 +97,11 @@ class SecureInput: ObservableObject {
}
if err == noErr {
enabled = desired
Self.logger.debug("secure input state=\(self.enabled)")
Self.logger.debug("secure input state=\(self.enabled, privacy: .public)")
return
}
Self.logger.warning("secure input apply failed err=\(err)")
Self.logger.warning("secure input apply failed err=\(err, privacy: .public)")
}
// MARK: Notifications
@ -117,7 +117,7 @@ class SecureInput: ObservableObject {
return
}
Self.logger.warning("secure input apply failed err=\(err)")
Self.logger.warning("secure input apply failed err=\(err, privacy: .public)")
}
@objc private func onDidResignActive(notification: NSNotification) {
@ -130,6 +130,6 @@ class SecureInput: ObservableObject {
return
}
Self.logger.warning("secure input apply failed err=\(err)")
Self.logger.warning("secure input apply failed err=\(err, privacy: .public)")
}
}

View File

@ -42,10 +42,21 @@ class ServiceProvider: NSObject {
// to their directories because that's the only thing we can open.
let directoryURLs = Set(
pathURLs.map { url -> URL in
url.hasDirectoryPath ? url : url.deletingLastPathComponent()
/// We check file system resources here because
/// NSURL doesn't append `/` when reading string contents from pasteboard
/// ```
/// NSURL(pasteboardPropertyList: "/System/Library".propertyList(), ofType: .fileURL)?.hasDirectoryPath
/// ```
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? url.hasDirectoryPath
return isDirectory ? url : url.deletingLastPathComponent()
}
)
guard !directoryURLs.isEmpty else {
error.pointee = Self.errorNoString
return
}
for url in directoryURLs {
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = url.path(percentEncoded: false)

View File

@ -29,5 +29,6 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi
window.center()
window.level = .popUpMenu
window.contentView = NSHostingView(rootView: ConfigurationErrorsView(model: self))
window.titlebarAppearsTransparent = true
}
}

View File

@ -46,8 +46,11 @@ struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
HStack {
Spacer()
Button("Ignore") { model.errors = [] }
.keyboardShortcut(.cancelAction)
Button("Reload Configuration") { reloadConfig() }
.keyboardShortcut(.defaultAction)
}
.controlSize(.large)
.padding([.bottom, .trailing])
}
.frame(minWidth: 480, maxWidth: 960, minHeight: 270)

View File

@ -256,7 +256,7 @@ class BaseTerminalController: NSWindowController,
// 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)")
Ghostty.logger.warning("failed to insert split: \(error, privacy: .public)")
return nil
}
@ -292,6 +292,7 @@ class BaseTerminalController: NSWindowController,
if to.isEmpty {
focusedSurface = nil
}
syncSurfaceTreeOcclusionState()
}
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
@ -316,19 +317,18 @@ class BaseTerminalController: NSWindowController,
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
}
func confirmClose(
func confirmCloseAsync(
messageText: String,
informativeText: String,
completion: @escaping () -> Void
) {
confirmButtonTitle: String = "Close",
) async -> NSApplication.ModalResponse? {
// If we already have an alert, we need to wait for that one.
guard alert == nil else { return }
guard alert == nil else { return nil }
// If there is no window to attach the modal then we assume success
// since we'll never be able to show the modal.
guard let window else {
completion()
return
return .OK
}
// If we need confirmation by any, show one confirmation for all windows
@ -336,22 +336,35 @@ class BaseTerminalController: NSWindowController,
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: confirmButtonTitle)
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window) { response in
let alertWindow = alert.window
// Store our alert so we only ever show one.
self.alert = alert
defer {
// This is important so that we avoid losing focus when Stage
// Manager is used (#8336)
alert.window.orderOut(nil)
self.alert = nil
if response == .alertFirstButtonReturn {
// This is important so that we avoid losing focus when Stage
// Manager is used (#8336)
alertWindow.orderOut(nil)
}
return await alert.beginSheetModal(for: window)
}
func confirmClose(
messageText: String,
informativeText: String,
confirmButtonTitle: String = "Close",
completion: @escaping () -> Void
) {
Task {
guard let response = await confirmCloseAsync(messageText: messageText, informativeText: informativeText, confirmButtonTitle: confirmButtonTitle) else {
completion()
return
}
if [.alertFirstButtonReturn, .OK].contains(response) {
completion()
}
}
// Store our alert so we only ever show one.
self.alert = alert
}
/// Prompt the user to change the tab/window title.
@ -458,8 +471,12 @@ class BaseTerminalController: NSWindowController,
replaceSurfaceTree(
surfaceTree.removing(node),
moveFocusTo: nextFocus,
moveFocusFrom: focusedSurface,
// When a non-focused surface is removed and this window stays as the key window,
// we should refocus the `focusedSurface` to make sure the window's firstResponder remains as it is.
//
// This is a weird workaround, since `resignFirstResponder` wasn't called on `focusedSurface` after drag,
// but the first responder became the window itself.
moveFocusTo: nextFocus ?? focusedSurface,
undoAction: "Close Terminal"
)
}
@ -713,7 +730,7 @@ class BaseTerminalController: NSWindowController,
do {
surfaceTree = try surfaceTree.resizing(node: targetNode, by: amount, in: spatialDirection, with: bounds)
} catch {
Ghostty.logger.warning("failed to resize split: \(error)")
Ghostty.logger.warning("failed to resize split: \(error, privacy: .public)")
}
}
@ -766,7 +783,8 @@ class BaseTerminalController: NSWindowController,
ghostty,
tree: newTree,
position: notification.userInfo?[Notification.Name.ghosttySurfaceDragEndedNoTargetPointKey] as? NSPoint,
confirmUndo: false)
confirmUndo: false,
inheritBackgroundOpacity: isBackgroundOpaque)
}
// MARK: Local Events
@ -886,7 +904,7 @@ class BaseTerminalController: NSWindowController,
do {
surfaceTree = try surfaceTree.replacing(node: node, with: resizedNode)
} catch {
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
Ghostty.logger.warning("failed to replace node during split resize: \(error, privacy: .public)")
}
}
@ -911,7 +929,7 @@ class BaseTerminalController: NSWindowController,
do {
newTree = try treeWithoutSource.inserting(view: source, at: destination, direction: direction)
} catch {
Ghostty.logger.warning("failed to insert surface during drop: \(error)")
Ghostty.logger.warning("failed to insert surface during drop: \(error, privacy: .public)")
return
}
@ -948,7 +966,7 @@ class BaseTerminalController: NSWindowController,
do {
newTree = try surfaceTree.inserting(view: source, at: destination, direction: direction)
} catch {
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error)")
Ghostty.logger.warning("failed to insert surface during cross-window drop: \(error, privacy: .public)")
return
}
@ -990,11 +1008,15 @@ class BaseTerminalController: NSWindowController,
// Do nothing if in fullscreen (transparency doesn't apply in fullscreen)
guard let window, !window.styleMask.contains(.fullScreen) else { return }
// Toggle between transparent and opaque
isBackgroundOpaque.toggle()
let newValue = !isBackgroundOpaque
let controllers = NSApplication.shared.windows.compactMap {
$0.windowController as? BaseTerminalController
}
// Update our appearance
syncAppearance()
for controller in controllers {
controller.isBackgroundOpaque = newValue
controller.syncAppearance()
}
}
/// Override this to resync any appearance related properties. This will be called automatically
@ -1178,10 +1200,8 @@ class BaseTerminalController: NSWindowController,
// MARK: NSWindowDelegate
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
/// Check whether window should be closed without showing an alert
func windowCanBeClosedWithoutConfirmation() -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
@ -1194,12 +1214,22 @@ class BaseTerminalController: NSWindowController,
// If our surfaces don't require confirmation, close.
if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
return false
}
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard !windowCanBeClosedWithoutConfirmation() else {
return true
}
// We require confirmation, so show an alert as long as we aren't already.
confirmClose(
messageText: "Close Terminal?",
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
) {
window.close()
) { [weak self] in
self?.window?.close()
}
return false
@ -1252,10 +1282,15 @@ class BaseTerminalController: NSWindowController,
}
func windowDidChangeOcclusionState(_ notification: Notification) {
syncSurfaceTreeOcclusionState()
}
private func syncSurfaceTreeOcclusionState() {
let visible = self.window?.occlusionState.contains(.visible) ?? false
for view in surfaceTree {
if let surface = view.surface {
if let surface = view.surface, view.isWindowVisible != visible {
ghostty_surface_set_occlusion(surface, visible)
view.isWindowVisible = visible
}
}
}

View File

@ -256,6 +256,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Get our parent. Our parent is the one explicitly given to us,
// otherwise the focused terminal, otherwise an arbitrary one.
let parent: NSWindow? = explicitParent ?? preferredParent?.window
if let parentController = parent?.windowController as? TerminalController {
c.isBackgroundOpaque = parentController.isBackgroundOpaque
}
if let parent, parent.styleMask.contains(.fullScreen) {
// If our previous window was fullscreen then we want our new window to
@ -339,8 +342,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
tree: SplitTree<Ghostty.SurfaceView>,
position: NSPoint? = nil,
confirmUndo: Bool = true,
inheritBackgroundOpacity: Bool? = nil
) -> TerminalController {
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
if let inheritBackgroundOpacity {
c.isBackgroundOpaque = inheritBackgroundOpacity
}
// Calculate the target frame based on the tree's view bounds
let treeSize: CGSize? = tree.root?.viewBounds()
@ -385,7 +392,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
withTarget: ghostty,
expiresAfter: target.undoExpiration
) { ghostty in
_ = TerminalController.newWindow(ghostty, tree: tree)
_ = TerminalController.newWindow(
ghostty,
tree: tree,
inheritBackgroundOpacity: inheritBackgroundOpacity
)
}
}
}
@ -420,6 +431,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Create a new window and add it to the parent
let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
controller.isBackgroundOpaque = parentController.isBackgroundOpaque
guard let window = controller.window else { return controller }
// If the parent is miniaturized, then macOS exhibits really strange behaviors
@ -1398,9 +1410,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// We also want to get notified of certain changes to update our appearance.
focusedSurface.$derivedConfig
.dropFirst()
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
focusedSurface.$backgroundColor
.dropFirst()
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
}

View File

@ -52,7 +52,7 @@ extension TerminalRestorable {
coder.encode(Self.version, forKey: Self.versionKey)
coder.encode(CodableBridge(self), forKey: Self.selfKey)
AppDelegate.logger.debug("saved terminal state: \(debugDescription)")
AppDelegate.logger.debug("saved terminal state: \(debugDescription, privacy: .public)")
}
}

View File

@ -15,20 +15,6 @@ extension TerminalRestorableState {
let effectiveFullscreenMode: FullscreenMode?
let tabColor: TerminalTabColor?
let titleOverride: String?
init(
focusedSurface: String?,
surfaceTree: SplitTree<ViewType>,
effectiveFullscreenMode: FullscreenMode?,
tabColor: TerminalTabColor?,
titleOverride: String?,
) {
self.focusedSurface = focusedSurface
self.surfaceTree = surfaceTree
self.effectiveFullscreenMode = effectiveFullscreenMode
self.tabColor = tabColor
self.titleOverride = titleOverride
}
}
}

View File

@ -190,14 +190,14 @@ extension Ghostty {
func newTab(surface: ghostty_surface_t) {
let action = "new_tab"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
func newWindow(surface: ghostty_surface_t) {
let action = "new_window"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -220,14 +220,14 @@ extension Ghostty {
func splitToggleZoom(surface: ghostty_surface_t) {
let action = "toggle_split_zoom"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
func toggleFullscreen(surface: ghostty_surface_t) {
let action = "toggle_fullscreen"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -248,21 +248,21 @@ extension Ghostty {
action = "reset_font_size"
}
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
func toggleTerminalInspector(surface: ghostty_surface_t) {
let action = "inspector:toggle"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
func resetTerminal(surface: ghostty_surface_t) {
let action = "reset"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
logger.warning("action failed action=\(action)")
logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -485,7 +485,7 @@ extension Ghostty {
break
default:
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)")
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue, privacy: .public)")
return false
}
@ -672,7 +672,7 @@ extension Ghostty {
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
return copyTitleToClipboard(app, target: target)
default:
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)")
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue, privacy: .public)")
return false
}
@ -979,7 +979,7 @@ extension Ghostty {
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let mode = FullscreenMode.from(ghostty: raw) else {
Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)")
Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue, privacy: .public)")
return
}
NotificationCenter.default.post(
@ -1399,7 +1399,7 @@ extension Ghostty {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { _, error in
if let error = error {
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
Ghostty.logger.error("Error while requesting notification authorization: \(error, privacy: .public)")
}
}

View File

@ -95,13 +95,13 @@ extension Ghostty {
// pop-up window too.
let diagsCount = ghostty_config_diagnostics_count(cfg)
if diagsCount > 0 {
logger.warning("config error: \(diagsCount) configuration errors on reload")
logger.warning("config error: \(diagsCount, privacy: .public) configuration errors on reload")
var diags: [String] = []
for i in 0..<diagsCount {
let diag = ghostty_config_get_diagnostic(cfg, UInt32(i))
let message = String(cString: diag.message)
diags.append(message)
logger.warning("config error: \(message)")
logger.warning("config error: \(message, privacy: .public)")
}
}

View File

@ -1,5 +1,6 @@
import Foundation
import GhosttyKit
import SwiftUI
extension Ghostty {
class OSSurfaceView: OSView, ObservableObject {
@ -115,13 +116,42 @@ extension Ghostty {
// MARK: Search State
extension Ghostty.OSSurfaceView {
class SearchState: ObservableObject {
@MainActor class SearchState: ObservableObject {
/// The pasteboard used to persist the search needle.
///
/// The `.find` pasteboard lets us sync our needle across the system and other find bars.
private let pasteboard: OSPasteboard
@Published var needle: String = ""
@Published var selected: UInt?
@Published var total: UInt?
init(from startSearch: Ghostty.Action.StartSearch) {
self.needle = startSearch.needle ?? ""
/// The range of the needle's text selection in the find bar.
@Published var needleSelection: Range<String.Index>?
init(
from startSearch: Ghostty.Action.StartSearch,
pasteboard: OSPasteboard = OSPasteboard.find
) {
self.pasteboard = pasteboard
if let needle = startSearch.needle, !needle.isEmpty {
self.needle = needle
writePasteboardNeedle()
} else {
readPasteboardNeedle()
}
}
func readPasteboardNeedle() {
let pasteboardNeedle = pasteboard.string
if let pasteboardNeedle, pasteboardNeedle != needle {
needle = pasteboardNeedle
needleSelection = needle.startIndex..<needle.endIndex
}
}
func writePasteboardNeedle() {
pasteboard.string = needle
}
}
@ -130,7 +160,7 @@ extension Ghostty.OSSurfaceView {
let action = "navigate_search:next"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
#endif
return false
}
@ -142,7 +172,7 @@ extension Ghostty.OSSurfaceView {
let action = "navigate_search:previous"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
#endif
return false
}

View File

@ -1,6 +1,7 @@
import SwiftUI
import UserNotifications
import GhosttyKit
import System
extension Ghostty {
/// Render a terminal for the active app in the environment.
@ -376,7 +377,11 @@ extension Ghostty {
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
BackportSelectionTextField(
"Search",
text: $searchState.needle,
selection: $searchState.needleSelection
)
.textFieldStyle(.plain)
.frame(width: 180)
.padding(.leading, 8)
@ -400,6 +405,21 @@ extension Ghostty {
.padding(.trailing, 8)
}
}
.onChange(of: searchState.needle) { _ in
searchState.writePasteboardNeedle()
}
.onReceive(
NotificationCenter.default.publisher(
for: OSApplication.didBecomeActiveNotification
)
) { _ in
// When the app becomes active, we want to check for external changes
// to our synced needle.
searchState.readPasteboardNeedle()
}
.onSubmit {
_ = surfaceView.navigateSearchToNext()
}
#if canImport(AppKit)
.onExitCommand {
if searchState.needle.isEmpty {
@ -412,10 +432,9 @@ extension Ghostty {
.backport.onKeyPress(.return) { modifiers in
if modifiers.contains(.shift) {
_ = surfaceView.navigateSearchToPrevious()
} else {
_ = surfaceView.navigateSearchToNext()
return .handled
}
return .handled
return .ignored
}
Button(action: {
@ -611,8 +630,13 @@ extension Ghostty {
/// Explicit font size to use in points
var fontSize: Float32?
/// Explicit working directory to set
var workingDirectory: String?
/// Explicit working directory. This is normalized on assignment to
/// remove any redundant and trailing path separators.
var workingDirectory: String? {
get { normalizedWorkingDirectory }
set { normalizedWorkingDirectory = newValue.map { FilePath($0).string } }
}
private var normalizedWorkingDirectory: String?
/// Explicit command to set
var command: String?

View File

@ -89,6 +89,12 @@ extension Ghostty {
// Whether the cursor is currently visible (not hidden by typing, etc.)
@Published private(set) var cursorVisible: Bool = true
/// Whether the belonging window is visible
///
/// We track this to restore surface occlusion state
/// after this surface is dragged to another window
var isWindowVisible = false
/// The configuration derived from the Ghostty config so we don't need to rely on references.
@Published private(set) var derivedConfig: DerivedConfig
@ -719,7 +725,18 @@ extension Ghostty {
// Update our derived config
DispatchQueue.main.async { [weak self] in
self?.derivedConfig = DerivedConfig(config)
guard let self else { return }
self.derivedConfig = DerivedConfig(config)
// If the cached OSC 11 background color disagrees with the new
// config-derived background, drop it so window chrome follows
// the new config (e.g., on light/dark theme auto-switch). The
// cached value is restored next time the terminal emits a
// color_change.
if let cached = self.backgroundColor,
cached != self.derivedConfig.backgroundColor {
self.backgroundColor = nil
}
}
}
@ -1582,7 +1599,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "copy_to_clipboard"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1590,7 +1607,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "paste_from_clipboard"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1598,7 +1615,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "paste_from_clipboard"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1606,7 +1623,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "paste_from_selection"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1614,7 +1631,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "select_all"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1622,7 +1639,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "start_search"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1630,7 +1647,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "search_selection"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1638,7 +1655,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "scroll_to_selection"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1654,7 +1671,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "end_search"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1662,7 +1679,7 @@ extension Ghostty {
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)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1690,7 +1707,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "reset"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1698,7 +1715,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
let action = "inspector:toggle"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
AppDelegate.logger.warning("action failed action=\(action)")
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
}
}
@ -1730,7 +1747,7 @@ extension Ghostty {
// so we need @MainActor since we're reading/writing view state.
UNUserNotificationCenter.current().add(request) { @MainActor error in
if let error = error {
AppDelegate.logger.error("Error scheduling user notification: \(error)")
AppDelegate.logger.error("Error scheduling user notification: \(error, privacy: .public)")
return
}

View File

@ -131,3 +131,49 @@ enum BackportNSGlassStyle {
}
#endif
}
/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection`
/// has no effect on versions below macOS 15/iOS 18.
struct BackportSelectionTextField: View {
private let titleKey: LocalizedStringKey
@Binding private var text: String
@Binding private var textSelection: Range<String.Index>?
init(
_ titleKey: LocalizedStringKey,
text: Binding<String>,
selection: Binding<Range<String.Index>?>
) {
self.titleKey = titleKey
self._text = text
self._textSelection = selection
}
var body: some View {
if #available(iOS 18.0, macOS 15, *) {
TextField(
titleKey,
text: _text,
selection: Binding(
get: {
if let textSelection {
TextSelection(range: textSelection)
} else {
nil
}
},
set: { selection in
if let selection,
case .selection(let range) = selection.indices {
self.textSelection = range
} else {
self.textSelection = nil
}
}
)
)
} else {
TextField(titleKey, text: _text)
}
}
}

View File

@ -11,6 +11,7 @@ typealias OSView = NSView
typealias OSColor = NSColor
typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
typealias OSApplication = NSApplication
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView
@ -36,6 +37,7 @@ typealias OSView = UIView
typealias OSColor = UIColor
typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
typealias OSApplication = UIApplication
protocol OSViewRepresentable: UIViewRepresentable {
associatedtype OSViewType: UIView

View File

@ -51,7 +51,7 @@ extension NSWindow {
var error: NSError?
let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error)
if let error {
Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription)")
Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription, privacy: .public)")
}
return success

View File

@ -0,0 +1,28 @@
#if canImport(AppKit)
/// Normalizes the interface between NSPasteboard and UIPasteboard for working with pasteboard
/// strings.
extension OSPasteboard {
@MainActor static let find = OSPasteboard(name: .find)
/// The pasteboard's current string value.
@MainActor var string: String? {
get {
string(forType: .string)
}
set {
clearContents()
if let newValue {
setString(newValue, forType: .string)
}
}
}
}
#elseif canImport(UIKit)
extension OSPasteboard {
static let find = OSPasteboard.withUniqueName()
}
#endif

View File

@ -0,0 +1,97 @@
import AppKit
import GhosttyKit
import Testing
@testable import Ghostty
@MainActor struct SurfaceView_SearchStateTests {
typealias SearchState = Ghostty.OSSurfaceView.SearchState
typealias StartSearch = Ghostty.Action.StartSearch
/// A unique pasteboard for each test case prevents flakiness.
let pasteboard = OSPasteboard.withUniqueName()
init() {
pasteboard.setString("pb", forType: .string)
}
@Test func init_withNilNeedle_readsPasteboardNeedle() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
}
@Test func init_withEmptyNeedle_readsPasteboardNeedle() {
"".withCString { needle in
let sut = SearchState(
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
}
}
@Test func init_withNeedle_setsNeedle() {
"start".withCString { needle in
let sut = SearchState(
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "start")
}
}
@Test func init_withNeedle_writesPasteboard() {
"start".withCString { needle in
_ = SearchState(
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(pasteboard.string(forType: .string) == "start")
}
}
@Test func writePasteboardNeedle_writesPasteboard() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.writePasteboardNeedle()
#expect(pasteboard.string(forType: .string) == "sut")
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
pasteboard.clearContents()
sut.needle = "sut"
sut.readPasteboardNeedle()
#expect(sut.needle == "sut")
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.readPasteboardNeedle()
#expect(sut.needle == "pb")
}
@Test func readPasteboardNeedle_setsNeedleSelectionRange() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.readPasteboardNeedle()
let expected = "pb".startIndex..<"pb".endIndex
#expect(sut.needleSelection == expected)
}
}

View File

@ -281,4 +281,113 @@ in {
server.wait_for_file("${user.home}/.terminfo/x/xterm-ghostty", timeout=30)
'';
};
# Regression test for the GTK audio-bell GStreamer thread leak. Each audio
# bell used to allocate a fresh gtk.MediaFile (and thus a GStreamer pipeline
# whose GL sink spawns gstglcontext/gldisplay-event threads that are never
# joined), leaking ~4 threads per ring; the fix reuses one MediaFile per
# surface. This rings many bells and asserts the GUI process thread count
# stays bounded. Runs under GNOME on Wayland so it exercises the real path.
bell-leak-check-gnome = mkTestGnome {
name = "bell-leak-check-gnome";
settings = {
# The VM has no GPU, so GNOME and Ghostty render via llvmpipe. Give the
# guest enough cores/RAM that software GL can bring up Ghostty's window
# before the +new-window D-Bus activation times out, and force clean
# software GL so mesa doesn't stall probing for absent hardware.
virtualisation.cores = 4;
virtualisation.memorySize = 4096;
environment.sessionVariables = {
LIBGL_ALWAYS_SOFTWARE = "1";
GALLIUM_DRIVER = "llvmpipe";
};
home-manager.users.ghostty = {
xdg.configFile = {
"ghostty/config".text = ''
bell-features = audio
bell-audio-path = ${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/bell.oga
bell-audio-volume = 0
'';
};
};
};
testScript = {nodes, ...}: let
user = nodes.machine.users.users.ghostty;
bus_path = "/run/user/${toString user.uid}/bus";
bus = "DBUS_SESSION_BUS_ADDRESS=unix:path=${bus_path}";
gdbus = "${bus} gdbus";
ghostty = "${bus} ghostty";
su = command: "su - ${user.name} -c '${command}'";
gseval = "call --session -d org.gnome.Shell -o /org/gnome/Shell -m org.gnome.Shell.Eval";
wm_class = su "${gdbus} ${gseval} global.display.focus_window.wm_class";
# Emits N BELs >100ms apart (which clears the bell rate-limit), then holds
# so the window (and its audio pipeline) stays alive while we sample. Run
# by typing its path into the open window; written as a script to avoid
# shell-escaping the BEL byte through the test driver.
ringBells = pkgs.writeShellScript "ring-bells" ''
for _ in $(seq 100); do printf '\a'; sleep 0.12; done
sleep 60
'';
in ''
# Thread count of the ghostty GUI process: the ghostty process with the
# most threads. The CLI also spawns 1-thread launcher/helper stubs (and
# this very command matches the pgrep), but those are filtered by the max.
def ghostty_threads():
out = machine.succeed(
"max=0; "
"for p in $(pgrep -f ghostty); do "
" n=$(ls /proc/$p/task 2>/dev/null | wc -l); "
" [ \"$n\" -gt \"$max\" ] && max=$n; "
"done; "
"echo $max"
).strip()
return int(out)
def window_open():
status, _ = machine.execute("${wm_class} | grep -q 'com.mitchellh.ghostty-debug'")
return status == 0
with subtest("boot and open a keep-alive ghostty window"):
start_all()
machine.wait_for_x()
machine.wait_for_file("${bus_path}")
machine.systemctl("enable app-com.mitchellh.ghostty-debug.service", user="${user.name}")
# Under software GL the +new-window D-Bus activation can exceed its
# client-side timeout even though the window still comes up, so we
# tolerate a failed call and (re)nudge until the window appears.
for _ in range(6):
machine.execute("${su "${ghostty} +new-window"}")
if window_open():
break
machine.sleep(5)
assert window_open(), "ghostty window never appeared"
machine.sleep(2)
with subtest("ring 100 bells and assert the thread count stays bounded"):
baseline = ghostty_threads()
# Ring the bells by running the script inside the focused window (type
# its path + Enter). A separate `ghostty -e` process can't open the
# display from the bare su environment, so we drive the open window.
machine.send_chars("${ringBells}\n")
# 100 bells * 0.12s + settle, within the script's trailing hold so the
# window (and its audio pipeline) is still alive when we sample.
machine.sleep(22)
final = ghostty_threads()
growth = final - baseline
print(f"bell-leak: baseline={baseline} final={final} growth={growth}")
# Pre-fix grows ~4 threads/bell (~+400 over 100 bells); the fix adds
# only one pipeline's worth of threads. 40 sits well clear of both.
assert growth <= 40, (
f"thread count grew by {growth} over 100 bells "
f"(baseline={baseline}, final={final}): audio-bell pipeline leak regressed"
)
'';
};
}

View File

@ -37,6 +37,13 @@ pub fn build(b: *std.Build) !void {
try android_ndk.addPaths(b, lib);
}
// Mainly for iOS simulators, but we add for all Darwin target for
// consistency.
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
try apple_sdk.addPaths(b, lib);
}
var flags: std.ArrayList([]const u8) = .empty;
defer flags.deinit(b.allocator);
try flags.appendSlice(b.allocator, &.{

View File

@ -12,5 +12,6 @@
},
.android_ndk = .{ .path = "../android-ndk" },
.apple_sdk = .{ .path = "../apple-sdk" },
},
}

353
po/eu.po Normal file
View File

@ -0,0 +1,353 @@
# Basque translations for com.mitchellh.ghostty package.
# Copyright (C) 2026 "Mitchell Hashimoto, Ghostty contributors"
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Mikel Larreategi <mlarreategi@codesyntax.com>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-05-01 12:56+0200\n"
"Last-Translator: Mikel Larreategi <mlarreategi@codesyntax.com>\n"
"Language-Team: Language eu\n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr "Ireki Ghostty-n"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:201
msgid "Authorize Clipboard Access"
msgstr "Baimendu arbelera sartzea"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:17
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:17
msgid "Deny"
msgstr "Ukatu"
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:18
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:18
msgid "Allow"
msgstr "Baimendu"
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:92
msgid "Remember choice for this split"
msgstr "Gogoratu zatiketa-aukera hau"
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:93
msgid "Reload configuration to show this prompt again"
msgstr "Birkargatu konfigurazioa eta erakutsi hau berriz"
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:7
#: src/apprt/gtk/ui/1.5/title-dialog.blp:8
msgid "Cancel"
msgstr "Utzi"
#: src/apprt/gtk/ui/1.2/close-confirmation-dialog.blp:8
#: src/apprt/gtk/ui/1.2/search-overlay.blp:85
#: src/apprt/gtk/ui/1.3/surface-child-exited.blp:17
msgid "Close"
msgstr "Itxi"
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:6
msgid "Configuration Errors"
msgstr "Konfigurazio erroreak"
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:7
msgid ""
"One or more configuration errors were found. Please review the errors below, "
"and either reload your configuration or ignore these errors."
msgstr ""
"Konfigurazio erroreren bat aurkitu da. Begiratu erroreak azpian, eta "
"birkargatu konfigurazioa edo baztertu erroreak."
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:10
msgid "Ignore"
msgstr "Baztertu"
#: src/apprt/gtk/ui/1.2/config-errors-dialog.blp:11
#: src/apprt/gtk/ui/1.2/surface.blp:366 src/apprt/gtk/ui/1.5/window.blp:300
msgid "Reload Configuration"
msgstr "Birkargatu konfigurazioa"
#: src/apprt/gtk/ui/1.2/debug-warning.blp:7
#: src/apprt/gtk/ui/1.3/debug-warning.blp:6
msgid ""
"⚠️ You're running a debug build of Ghostty! Performance will be degraded."
msgstr ""
"⚠️ Garapeneko Ghostty bertsio bat erabiltzen ari zara! Errendimendua ez da "
"ona izango"
#: src/apprt/gtk/ui/1.5/inspector-window.blp:5
msgid "Ghostty: Terminal Inspector"
msgstr "Ghostty: terminal ikuskatzailea"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:29
msgid "Find…"
msgstr "Bilatu…"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:64
msgid "Previous Match"
msgstr "Aurreko bilaketa"
#: src/apprt/gtk/ui/1.2/search-overlay.blp:74
msgid "Next Match"
msgstr "Hurrengo bilaketa"
#: src/apprt/gtk/ui/1.2/surface.blp:6
msgid "Oh, no."
msgstr "Oh, ez!"
#: src/apprt/gtk/ui/1.2/surface.blp:7
msgid "Unable to acquire an OpenGL context for rendering."
msgstr "Ezin izan da OpenGL kontestua erabili."
#: src/apprt/gtk/ui/1.2/surface.blp:97
msgid ""
"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."
msgstr ""
"Terminal hau irakurtzeko moduan dago. Ikusi, aukeratu eta kontestuan zehar "
"gora eta behera ibili zaitezke, baina ez da sarrera ekintzarik bidaliko."
#: src/apprt/gtk/ui/1.2/surface.blp:107
msgid "Read-only"
msgstr "Irakurtzeko-bakarrik"
#: src/apprt/gtk/ui/1.2/surface.blp:260 src/apprt/gtk/ui/1.5/window.blp:200
msgid "Copy"
msgstr "Kopiatu"
#: src/apprt/gtk/ui/1.2/surface.blp:265 src/apprt/gtk/ui/1.5/window.blp:205
msgid "Paste"
msgstr "Itsatsi"
#: src/apprt/gtk/ui/1.2/surface.blp:270
msgid "Notify on Next Command Finish"
msgstr "Jakinarazi hurrengo komandoa amaitzean"
#: src/apprt/gtk/ui/1.2/surface.blp:277 src/apprt/gtk/ui/1.5/window.blp:273
msgid "Clear"
msgstr "Garbitu"
#: src/apprt/gtk/ui/1.2/surface.blp:282 src/apprt/gtk/ui/1.5/window.blp:278
msgid "Reset"
msgstr "Berrezarri"
#: src/apprt/gtk/ui/1.2/surface.blp:289 src/apprt/gtk/ui/1.5/window.blp:242
msgid "Split"
msgstr "Zatitu"
#: src/apprt/gtk/ui/1.2/surface.blp:292 src/apprt/gtk/ui/1.5/window.blp:245
msgid "Change Title…"
msgstr "Aldatu izenburua…"
#: src/apprt/gtk/ui/1.2/surface.blp:297 src/apprt/gtk/ui/1.5/window.blp:177
#: src/apprt/gtk/ui/1.5/window.blp:250
msgid "Split Up"
msgstr "Zatitu gorantz"
#: src/apprt/gtk/ui/1.2/surface.blp:303 src/apprt/gtk/ui/1.5/window.blp:182
#: src/apprt/gtk/ui/1.5/window.blp:255
msgid "Split Down"
msgstr "Zatitu beherantz"
#: src/apprt/gtk/ui/1.2/surface.blp:309 src/apprt/gtk/ui/1.5/window.blp:187
#: src/apprt/gtk/ui/1.5/window.blp:260
msgid "Split Left"
msgstr "Zatitu ezkerrera"
#: src/apprt/gtk/ui/1.2/surface.blp:315 src/apprt/gtk/ui/1.5/window.blp:192
#: src/apprt/gtk/ui/1.5/window.blp:265
msgid "Split Right"
msgstr "Zatitu eskumara"
#: src/apprt/gtk/ui/1.2/surface.blp:322
msgid "Tab"
msgstr "Fitxa"
#: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224
#: src/apprt/gtk/ui/1.5/window.blp:320
msgid "Change Tab Title…"
msgstr "Aldatu fitxaren izenburua…"
#: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57
#: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229
msgid "New Tab"
msgstr "Fitxa berria"
#: src/apprt/gtk/ui/1.2/surface.blp:335 src/apprt/gtk/ui/1.5/window.blp:234
msgid "Close Tab"
msgstr "Itxi fitxa"
#: src/apprt/gtk/ui/1.2/surface.blp:342
msgid "Window"
msgstr "Leihoa"
#: src/apprt/gtk/ui/1.2/surface.blp:345 src/apprt/gtk/ui/1.5/window.blp:212
msgid "New Window"
msgstr "Leiho berria"
#: src/apprt/gtk/ui/1.2/surface.blp:350 src/apprt/gtk/ui/1.5/window.blp:217
msgid "Close Window"
msgstr "Itxi leihoa"
#: src/apprt/gtk/ui/1.2/surface.blp:358
msgid "Config"
msgstr "Konfigurazioa"
#: src/apprt/gtk/ui/1.2/surface.blp:361 src/apprt/gtk/ui/1.5/window.blp:295
msgid "Open Configuration"
msgstr "Ireki konfigurazioa"
#: src/apprt/gtk/ui/1.5/title-dialog.blp:5
msgid "Leave blank to restore the default title."
msgstr "Utzi hutsik defektuzko izenburua berrezartzeko"
#: src/apprt/gtk/ui/1.5/title-dialog.blp:9
msgid "OK"
msgstr "OK"
#: src/apprt/gtk/ui/1.5/window.blp:58 src/apprt/gtk/ui/1.5/window.blp:108
msgid "New Split"
msgstr "Zatitu"
#: src/apprt/gtk/ui/1.5/window.blp:68 src/apprt/gtk/ui/1.5/window.blp:126
msgid "View Open Tabs"
msgstr "Ikusi irekitako fitxak"
#: src/apprt/gtk/ui/1.5/window.blp:78 src/apprt/gtk/ui/1.5/window.blp:140
msgid "Main Menu"
msgstr "Menu nagusia"
#: src/apprt/gtk/ui/1.5/window.blp:285
msgid "Command Palette"
msgstr "Komando paleta"
#: src/apprt/gtk/ui/1.5/window.blp:290
msgid "Terminal Inspector"
msgstr "Terminal ikuskatzailea"
#: src/apprt/gtk/ui/1.5/window.blp:307 src/apprt/gtk/class/window.zig:1727
msgid "About Ghostty"
msgstr "Ghosttyri buruz"
#: src/apprt/gtk/ui/1.5/window.blp:312
msgid "Quit"
msgstr "Irten"
#: src/apprt/gtk/ui/1.5/command-palette.blp:17
msgid "Execute a command…"
msgstr "Exekutatu komando bat…"
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:198
msgid ""
"An application is attempting to write to the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Aplikazio bat arbelean idatzi nahian dabil. Hau da arbelaren uneko "
"edukia."
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:202
msgid ""
"An application is attempting to read from the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
"Aplikazio bat arbeletik irakurri nahian dabil. Hau da arbelaren gaur egungo "
"edukia."
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:205
msgid "Warning: Potentially Unsafe Paste"
msgstr "Oharra: hau itsastea ez da segurua"
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:206
msgid ""
"Pasting this text into the terminal may be dangerous as it looks like some "
"commands may be executed."
msgstr ""
"Testu hau terminalean itsastea arriskutsua izan daiteke komandoren batzuk "
"exekutatzea ekar dezakeelako."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:184
msgid "Quit Ghostty?"
msgstr "Ghostty itxi?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:185
msgid "Close Tab?"
msgstr "Fitxa itxi?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:186
msgid "Close Window?"
msgstr "Leihoa itxi?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:187
msgid "Close Split?"
msgstr "Zatikatzea itxi?"
#: src/apprt/gtk/class/close_confirmation_dialog.zig:193
msgid "All terminal sessions will be terminated."
msgstr "Terminal saio guztiak itxi egingo dira."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:194
msgid "All terminal sessions in this tab will be terminated."
msgstr "Fitxa honetako terminal saioa itxi egingo da."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:195
msgid "All terminal sessions in this window will be terminated."
msgstr "Leiho honetako terminal saio guztiak itxi egingo dira."
#: src/apprt/gtk/class/close_confirmation_dialog.zig:196
msgid "The currently running process in this split will be terminated."
msgstr "Zatikatze honetan martxan dagoen prozesua itxi egingo da."
#: src/apprt/gtk/class/surface.zig:1108
msgid "Command Finished"
msgstr "Komandoa amaitu da"
#: src/apprt/gtk/class/surface.zig:1109
msgid "Command Succeeded"
msgstr "Komandoa ondo amaitu da"
#: src/apprt/gtk/class/surface.zig:1110
msgid "Command Failed"
msgstr "Komandoak huts egin du"
#: src/apprt/gtk/class/surface_child_exited.zig:109
msgid "Command succeeded"
msgstr "Komandoa ondo amaitu da"
#: src/apprt/gtk/class/surface_child_exited.zig:113
msgid "Command failed"
msgstr "Komandoak huts egin du"
#: src/apprt/gtk/class/title_dialog.zig:225
msgid "Change Terminal Title"
msgstr "Aldatu terminalaren izenburua"
#: src/apprt/gtk/class/title_dialog.zig:226
msgid "Change Tab Title"
msgstr "Aldatu fitxaren izenburua"
#: src/apprt/gtk/class/window.zig:1007
msgid "Reloaded the configuration"
msgstr "Konfigurazioa berriz kargatu da"
#: src/apprt/gtk/class/window.zig:1566
msgid "Copied to clipboard"
msgstr "Arbelera kopiatu da"
#: src/apprt/gtk/class/window.zig:1568
msgid "Cleared clipboard"
msgstr "Arbela garbitu da"
#: src/apprt/gtk/class/window.zig:1708
msgid "Ghostty Developers"
msgstr "Ghostty garatzaileak"

View File

@ -50,6 +50,7 @@ export XLOCALEDIR="${SNAP}/usr/share/X11/locale"
export GTK_PATH="$SNAP/usr/lib/$ARCH/gtk-4.0"
export GIO_MODULE_DIR="$SNAP/usr/lib/$ARCH/gio/modules"
unset GIO_EXTRA_MODULES
export TERMINFO_DIRS="${SNAP}/share/terminfo${TERMINFO_DIRS:+:$TERMINFO_DIRS}"
# Gdk-pixbuf loaders
mkdir -p "$SNAP_USER_COMMON/.cache"

File diff suppressed because it is too large Load Diff

View File

@ -336,6 +336,7 @@ pub const App = struct {
) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
switch (action) {
.new_window => return false,
.toggle_quick_terminal => return false,
}
}
};

View File

@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig");
const Application = @import("class/application.zig").Application;
const Surface = @import("Surface.zig");
const ipcNewWindow = @import("ipc/new_window.zig").newWindow;
const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal;
const log = std.log.scoped(.gtk);
@ -84,6 +85,7 @@ pub fn performIpc(
) !bool {
switch (action) {
.new_window => return try ipcNewWindow(alloc, target, value),
.toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target),
}
}

View File

@ -1419,6 +1419,7 @@ pub const Application = extern struct {
.init("present-surface", actionPresentSurface, t_variant_type),
.init("quit", actionQuit, null),
.init("reload-config", actionReloadConfig, null),
.init("toggle-quick-terminal", actionToggleQuickTerminal, null),
};
ext.actions.add(Self, self, &actions);
@ -1669,6 +1670,17 @@ pub const Application = extern struct {
};
}
fn actionToggleQuickTerminal(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const priv = self.private();
priv.core_app.performAction(self.rt(), .toggle_quick_terminal) catch |err| {
log.warn("error toggling quick terminal err={}", .{err});
};
}
fn actionQuit(
_: *gio.SimpleAction,
_: ?*glib.Variant,

View File

@ -158,6 +158,13 @@ pub const SplitTree = extern struct {
/// used to debounce updates.
rebuild_source: ?c_uint = null,
/// The source that we use to restore focus. With enough nested
/// splits, some surfaces might initially be allocated a width or
/// height of 0 which causes them to get unmapped and lose focus.
/// We can reliably restore focus to the last focused surface only
/// once it is mapped again.
restore_focus_source: ?c_uint = null,
/// Used to store state about a pending surface close for the
/// close dialog.
pending_close: ?Surface.Tree.Node.Handle,
@ -415,6 +422,13 @@ pub const SplitTree = extern struct {
self,
.{ .detail = "focused" },
);
_ = gobject.Object.signals.notify.connect(
surface,
*Self,
propSurfaceMapped,
self,
.{ .detail = "mapped" },
);
}
}
@ -571,6 +585,12 @@ pub const SplitTree = extern struct {
}
priv.rebuild_source = null;
}
if (priv.restore_focus_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove restore_focus source", .{});
}
priv.restore_focus_source = null;
}
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
@ -766,6 +786,24 @@ pub const SplitTree = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
}
fn propSurfaceMapped(
surface: *Surface,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
if (!surface.getMapped()) return;
// We could add the idle callback only if this is actually the last
// focused surface. But we can avoid that check because usually all
// the surfaces get mapped at once, so the idle callback will run
// only once anyway.
const priv = self.private();
if (priv.restore_focus_source == null) priv.restore_focus_source = glib.idleAdd(
onRestoreFocus,
self,
);
}
fn propTree(
self: *Self,
_: *gobject.ParamSpec,
@ -779,14 +817,20 @@ pub const SplitTree = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"has-surfaces".impl.param_spec);
self.as(gobject.Object).notifyByPspec(properties.@"is-zoomed".impl.param_spec);
// If we were planning a rebuild, always remove that so we can
// start from a clean slate.
// If we were planning a rebuild or focus restore, always remove
// that so we can start from a clean slate.
if (priv.rebuild_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove rebuild source", .{});
}
priv.rebuild_source = null;
}
if (priv.restore_focus_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove restore_focus source", .{});
}
priv.restore_focus_source = null;
}
// If we transitioned to an empty tree, clear immediately instead of
// waiting for an idle callback. Delaying teardown can keep the last
@ -842,6 +886,26 @@ pub const SplitTree = extern struct {
return 0;
}
fn onRestoreFocus(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
// Always mark our source as null since we're done.
const priv = self.private();
priv.restore_focus_source = null;
// If we have a last-focused surface and it is mapped, restore focus
// to it. Depending on the available size, the surface might already
// have focus because it never got unmapped. In that case grabbing
// focus will have no effect.
if (priv.last_focused.get()) |v| {
defer v.unref();
if (v.getMapped()) {
v.grabFocus();
}
}
return 0;
}
/// Builds the widget tree associated with a surface split tree.
///
/// Returned widgets are expected to be attached to a parent by the caller.
@ -1044,6 +1108,12 @@ const SplitTreeSplit = extern struct {
/// Source to handle repositioning the split when properties change.
idle: ?c_uint = null,
/// Whether the max-position/position property of the gtk.Paned widget
/// changed. We use these to distinguish between a resize and the user
/// manually moving the split divider. See the "on-idle" function.
max_changed: bool = false,
pos_changed: bool = false,
// Template bindings
paned: *gtk.Paned,
@ -1083,21 +1153,37 @@ const SplitTreeSplit = extern struct {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
fn refresh(self: *Self) void {
const priv = self.private();
if (priv.idle == null) priv.idle = glib.idleAdd(
onIdle,
self,
);
}
// We need to keep the split ratios from the tree datastructure and
// widget tree in sync. Using the max-position and position properties
// of the gtk.Paned widget, we can distinguish a resize from a manual
// update (e.g. the user dragging the divider).If max-position changes,
// we always have a widget resize. Usually position will change as well
// but it might not if the size change is small enough. If only position
// changes, we have a manual human update.
//
// This is a hack, it relies on the timing of property notifcations.
// From looking at the GTK source code, it should not be possible that
// we interpret a position change from a resize as a manual update.
// When a gtk.Paned is resized, internally the gtk_paned_calc_position
// function will change both max-position and position and synchronously
// call our propMaxPosition and propPosition functions. I.e. when the
// widget is resized, it should not be possible for onIdle to run before
// we have been notified of both property changes.
fn onIdle(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
const paned = priv.paned;
// Our idle source is always over
priv.idle = null;
// Clear source and fields at the end. Otherwise if setPosition is
// called below, propPosition is triggered and would add another
// idle callback before this one is finished.
defer priv.idle = null;
defer priv.max_changed = false;
defer priv.pos_changed = false;
if (!priv.max_changed and !priv.pos_changed) {
return 0;
}
// Get our split. This is the most dangerous part of this entire
// widget. We assume that this widget is always a child of a
@ -1132,16 +1218,6 @@ const SplitTreeSplit = extern struct {
);
break :max gobject.ext.Value.get(&val, c_int);
};
const pos_set: bool = max: {
var val = gobject.ext.Value.new(c_int);
defer val.unset();
gobject.Object.getProperty(
paned.as(gobject.Object),
"position-set",
&val,
);
break :max gobject.ext.Value.get(&val, c_int) != 0;
};
// We don't actually use min, but we don't expect this to ever
// be non-zero, so let's add an assert to ensure that.
@ -1172,51 +1248,51 @@ const SplitTreeSplit = extern struct {
return 0;
}
// If we're out of bounds, then we need to either set the position
// to what we expect OR update our expected ratio.
// If we've never set the position, then we set it to the desired.
if (!pos_set) {
if (priv.max_changed) {
// Widget got resized, update position to match desired ratio.
// Note that if max-position is small, it might not be possible
// to accurately set the desired ratio. E.g. with max-position=2
// you can only have ratios 0, 0.5 and 1.
const desired_pos: c_int = desired_pos: {
const max_f64: f64 = @floatFromInt(max);
break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio));
};
paned.setPosition(desired_pos);
return 0;
} else {
// If only position changed, this is a manual human update and
// we need to write our update back to the tree.
tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
}
// If we've set the position, then this is a manual human update
// and we need to write our update back to the tree.
tree.resizeInPlace(priv.handle, @floatCast(current_ratio));
return 0;
}
//---------------------------------------------------------------
// Signal handlers
fn propPosition(
_: *gtk.Paned,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.refresh();
}
fn propMaxPosition(
_: *gtk.Paned,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.refresh();
const priv = self.private();
priv.max_changed = true;
if (priv.idle == null) priv.idle = glib.idleAdd(
onIdle,
self,
);
}
fn propMinPosition(
fn propPosition(
_: *gtk.Paned,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
self.refresh();
const priv = self.private();
priv.pos_changed = true;
if (priv.idle == null) priv.idle = glib.idleAdd(
onIdle,
self,
);
}
//---------------------------------------------------------------
@ -1275,7 +1351,6 @@ const SplitTreeSplit = extern struct {
// Template Callbacks
class.bindTemplateCallback("notify_max_position", &propMaxPosition);
class.bindTemplateCallback("notify_min_position", &propMinPosition);
class.bindTemplateCallback("notify_position", &propPosition);
// Virtual methods

View File

@ -169,6 +169,24 @@ pub const Surface = extern struct {
);
};
pub const mapped = struct {
pub const name = "mapped";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.privateFieldAccessor(
Self,
Private,
&Private.offset,
"mapped",
),
},
);
};
pub const @"min-size" = struct {
pub const name = "min-size";
const impl = gobject.ext.defineProperty(
@ -592,11 +610,15 @@ pub const Surface = extern struct {
/// focus events.
focused: bool = true,
/// Whether the GLArea widget is mapped. Some operations like grabbing
/// focus only work if a widget is mapped.
mapped: bool = false,
/// Whether this surface is "zoomed" or not. A zoomed surface
/// shows up taking the full bounds of a split view.
zoom: bool = false,
/// The GLAarea that renders the actual surface. This is a binding
/// The GLArea that renders the actual surface. This is a binding
/// to the template so it doesn't have to be unrefed manually.
gl_area: *gtk.GLArea,
@ -652,6 +674,12 @@ pub const Surface = extern struct {
// false by a parent widget.
bell_ringing: bool = false,
// The audio bell's MediaFile, reused across bells so we don't leak a
// GStreamer pipeline (and its GL threads) on every ring. Built lazily
// on the first audio bell and rebuilt when `bell-audio-path` changes;
// unref'd on dispose. See ringBell and media.zig.
bell_media: ?*gtk.MediaFile = null,
/// True if this surface is in an error state. This is currently
/// a simple boolean with no additional information on WHAT the
/// error state is, because we don't yet need it or use it. For now,
@ -1768,6 +1796,7 @@ pub const Surface = extern struct {
priv.mouse_shape = .text;
priv.mouse_hidden = false;
priv.focused = true;
priv.mapped = false;
priv.size = .{ .width = 0, .height = 0 };
priv.vadj_signal_group = null;
@ -1831,6 +1860,11 @@ pub const Surface = extern struct {
priv.config = null;
}
if (priv.bell_media) |v| {
v.unref();
priv.bell_media = null;
}
if (priv.vadj_signal_group) |group| {
group.setTarget(null);
group.as(gobject.Object).unref();
@ -2019,6 +2053,11 @@ pub const Surface = extern struct {
return self.private().focused;
}
/// Returns true if the GLArea of this surface is mapped.
pub fn getMapped(self: *Self) bool {
return self.private().mapped;
}
/// Change the configuration for this surface.
pub fn setConfig(self: *Self, config: *Config) void {
const priv = self.private();
@ -2458,8 +2497,15 @@ pub const Surface = extern struct {
1.0,
);
const media_file = media.fromFilename(path) orelse break :audio;
media.playMediaFile(media_file, volume, required);
// Reuse one MediaFile per surface (rebuilt only when the path
// changes) so each bell replays the same pipeline instead of
// leaking a fresh one. Assign unconditionally: bellMediaFile frees
// any stale MediaFile and returns the current slot value (possibly
// null if the path is now inaccessible), so priv.bell_media never
// dangles.
priv.bell_media = media.bellMediaFile(priv.bell_media, path, required);
const media_file = priv.bell_media orelse break :audio;
media.playBell(media_file, volume);
}
}
@ -3250,6 +3296,35 @@ pub const Surface = extern struct {
priv.im_context.as(gtk.IMContext).setClientWidget(null);
}
fn glareaMap(
_: *gtk.GLArea,
self: *Self,
) callconv(.c) void {
self.updateMapped(true);
self.updateOcclusion(true);
}
fn glareaUnmap(
_: *gtk.GLArea,
self: *Self,
) callconv(.c) void {
self.updateMapped(false);
self.updateOcclusion(false);
}
fn updateMapped(self: *Self, mapped: bool) void {
const priv = self.private();
priv.mapped = mapped;
self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec);
}
fn updateOcclusion(self: *Self, visible: bool) void {
const surface = self.core() orelse return;
surface.occlusionCallback(visible) catch |err| {
log.warn("error in occlusion callback err={}", .{err});
};
}
fn glareaRender(
_: *gtk.GLArea,
_: *gdk.GLContext,
@ -3560,6 +3635,8 @@ pub const Surface = extern struct {
class.bindTemplateCallback("drop", &dtDrop);
class.bindTemplateCallback("gl_realize", &glareaRealize);
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);
class.bindTemplateCallback("gl_map", &glareaMap);
class.bindTemplateCallback("gl_unmap", &glareaUnmap);
class.bindTemplateCallback("gl_render", &glareaRender);
class.bindTemplateCallback("gl_resize", &glareaResize);
class.bindTemplateCallback("im_preedit_start", &imPreeditStart);
@ -3592,6 +3669,7 @@ pub const Surface = extern struct {
properties.@"error".impl,
properties.@"font-size-request".impl,
properties.focused.impl,
properties.mapped.impl,
properties.@"key-sequence".impl,
properties.@"key-table".impl,
properties.@"min-size".impl,

View File

@ -220,6 +220,9 @@ pub const Window = extern struct {
/// behaves slightly differently under certain scenarios.
quick_terminal: bool = false,
/// Timeout source to react to this window becoming (in)active.
handle_active_state_source: ?c_uint = null,
/// The window decoration override. If this is not set then we'll
/// inherit whatever the config has. This allows overriding the
/// config on a per-window basis.
@ -855,6 +858,38 @@ pub const Window = extern struct {
}
}
/// Callback to handle this window becoming active or inactive.
/// Triggered by propIsActive with a timeout to debounce temporary
/// changes in active state.
fn handleActiveState(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
priv.handle_active_state_source = null;
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
if (cfg.get().@"quick-terminal-autohide" and
self.as(gtk.Window).isActive() == 0 and
self.as(gtk.Widget).isVisible() == 1)
{
self.toggleVisibility();
}
}
}
// Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return 0;
self.winproto().setUrgent(false) catch |err| {
log.warn(
"winproto failed to reset urgency={}",
.{err},
);
};
return 0;
}
//---------------------------------------------------------------
// Properties
@ -1076,27 +1111,34 @@ pub const Window = extern struct {
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
if (cfg.get().@"quick-terminal-autohide" and
self.as(gtk.Window).isActive() == 0 and
self.as(gtk.Widget).isVisible() == 1)
{
self.toggleVisibility();
}
}
}
const priv = self.private();
// Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return;
self.winproto().setUrgent(false) catch |err| {
log.warn(
"winproto failed to reset urgency={}",
.{err},
// Use a timeout callback to wait for focus state to settle,
// because depending on the windowing backend the window might
// become inactive and immediately active again. This happens
// e.g. on Wayland when opening a context menu or a submenu
// inside a context menu.
if (priv.handle_active_state_source == null) {
priv.handle_active_state_source = glib.timeoutAddFull(
// Use priority of an idle callback instead of the higher
// default timeout priority. This allows us to use a shorter
// timeout duration.
glib.PRIORITY_DEFAULT_IDLE,
// 50ms was chosen to be conservative. From testing we know
// that, depending on the backend and system performance, a
// shorter timeout or just an idle callback can be enough for
// the focus to settle. On the other hand a delay of e.g. 10ms
// does not work reliably on some slow systems. The downside
// of a high value is that some operations in handleActiveState,
// e.g. hiding the quick-terminal, will be visibly delayed.
// However, 50ms should barely be noticeable. We can change
// this in the future if necessary.
50,
handleActiveState,
self,
null,
);
};
}
}
fn propGdkSurfaceDims(
@ -1215,6 +1257,13 @@ pub const Window = extern struct {
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.handle_active_state_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove handle active state source", .{});
}
priv.handle_active_state_source = null;
}
priv.command_palette.set(null);
if (priv.config) |v| {

View File

@ -0,0 +1,24 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const apprt = @import("../../../apprt.zig");
const DBus = @import("DBus.zig");
/// Use a D-Bus method call to toggle the quick terminal on GTK.
///
/// `ghostty +toggle-quick-terminal` is equivalent to the following command
/// (on a release build):
///
/// ```sh
/// gdbus call --session \
/// --dest com.mitchellh.ghostty \
/// --object-path /com/mitchellh/ghostty \
/// --method org.gtk.Actions.Activate \
/// toggle-quick-terminal [] []
/// ```
pub fn toggleQuickTerminal(alloc: Allocator, target: apprt.ipc.Target) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool {
var dbus = try DBus.init(alloc, target, "toggle-quick-terminal");
defer dbus.deinit(alloc);
try dbus.send();
return true;
}

View File

@ -44,9 +44,38 @@ pub fn fromResource(path: [:0]const u8) ?*gtk.MediaFile {
return gtk.MediaFile.newForResource(path);
}
pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) void {
// If the audio file is marked as required, we'll emit an error if
// there was a problem playing it. Otherwise there will be silence.
/// Get-or-create a reusable bell MediaFile targeting `path`.
///
/// `current` is the surface's currently-cached MediaFile (or null). If it
/// already targets `path` it is returned unchanged; otherwise it is unref'd and
/// a fresh MediaFile is built for `path`. Returns null (after freeing `current`)
/// if `path` is inaccessible, leaving the caller's slot empty.
///
/// Reusing one MediaFile per surface is what prevents the GStreamer pipeline
/// leak: `gtk.MediaFile.newForFilename` spins up a full pipeline (and, via the
/// GTK4 GStreamer backend's GL sink, gstglcontext/gldisplay-event threads) that
/// is never torn down on the happy path, so allocating one per bell leaked a
/// pipeline + its threads on every ring. See the caller in surface.zig.
pub fn bellMediaFile(
current: ?*gtk.MediaFile,
path: [:0]const u8,
required: bool,
) ?*gtk.MediaFile {
if (current) |media_file| {
if (isForPath(media_file, path)) return media_file;
media_file.unref();
}
const media_file = fromFilename(path) orelse return null;
// If the audio file is marked as required, we'll emit an error if there
// was a problem playing it. Otherwise there will be silence. We connect
// this once, here, because the MediaFile is reused across bells.
//
// NOTE: we intentionally do NOT connect notify::ended to unref. The
// MediaFile is owned by the surface and replayed via `seek(0)` for every
// bell; unref'ing on `ended` is precisely what previously discarded (and
// leaked) a pipeline per ring.
if (required) {
_ = gobject.Object.signals.notify.connect(
media_file,
@ -57,21 +86,27 @@ pub fn playMediaFile(media_file: *gtk.MediaFile, volume: f64, required: bool) vo
);
}
// Watch for the "ended" signal so that we can clean up after
// ourselves.
_ = gobject.Object.signals.notify.connect(
media_file,
?*anyopaque,
mediaFileEnded,
null,
.{ .detail = "ended" },
);
return media_file;
}
/// (Re)play `media_file` at `volume`. `seek(0)` rewinds first so that a
/// previously-ended stream plays again; without it playback only ever happens
/// once (see #8957). Safe on a freshly-created stream as well.
pub fn playBell(media_file: *gtk.MediaFile, volume: f64) void {
const media_stream = media_file.as(gtk.MediaStream);
media_stream.setVolume(volume);
media_stream.seek(0);
media_stream.play();
}
/// Whether `media_file` was created for `path`.
fn isForPath(media_file: *gtk.MediaFile, path: [:0]const u8) bool {
const file = media_file.getFile() orelse return false;
const cur = file.getPath() orelse return false;
defer glib.free(cur);
return std.mem.eql(u8, std.mem.span(cur), path);
}
fn mediaFileError(
media_file: *gtk.MediaFile,
_: *gobject.ParamSpec,
@ -93,10 +128,30 @@ fn mediaFileError(
});
}
fn mediaFileEnded(
media_file: *gtk.MediaFile,
_: *gobject.ParamSpec,
_: ?*anyopaque,
) callconv(.c) void {
media_file.unref();
test "bellMediaFile reuses one MediaFile per path" {
// Regression guard for the audio-bell thread leak: each bell must replay a
// single cached MediaFile, not allocate a fresh GStreamer pipeline (which
// leaked gstglcontext/gldisplay-event threads) per ring. We assert the
// reuse contract of bellMediaFile directly; this needs no display and no
// playback (MediaFile is lazy), only that the path comparison drives reuse.
const testing = std.testing;
// The files need not exist: MediaFile only records the path until played.
const path_a: [:0]const u8 = "/tmp/ghostty-bell-test-a.oga";
const path_b: [:0]const u8 = "/tmp/ghostty-bell-test-b.oga";
var current = bellMediaFile(null, path_a, false) orelse return error.SkipZigTest;
const first = current;
try testing.expect(isForPath(current, path_a));
// Same path => identical object (the leak regression is rebuilding here).
current = bellMediaFile(current, path_a, false).?;
try testing.expectEqual(first, current);
// Changed path => rebuilt object targeting the new path (old one freed).
current = bellMediaFile(current, path_b, false) orelse return error.SkipZigTest;
try testing.expect(isForPath(current, path_b));
try testing.expect(!isForPath(current, path_a));
current.unref();
}

View File

@ -23,6 +23,8 @@ Overlay terminal_page {
GLArea gl_area {
realize => $gl_realize();
unrealize => $gl_unrealize();
map => $gl_map();
unmap => $gl_unmap();
render => $gl_render();
resize => $gl_resize();
hexpand: true;

View File

@ -13,7 +13,6 @@ template $GhosttySplitTreeSplit: Adw.Bin {
Adw.Bin {
Paned paned {
notify::max-position => $notify_max_position();
notify::min-position => $notify_min_position();
notify::position => $notify_position();
}
}

View File

@ -73,6 +73,9 @@ pub const Action = union(enum) {
/// The arguments to pass to Ghostty as the command.
new_window: NewWindow,
/// Toggle the quick terminal.
toggle_quick_terminal: void,
pub const NewWindow = struct {
/// A list of command arguments to launch in the new window. If this is
/// `null` the command configured in the config or the user's default
@ -113,6 +116,7 @@ pub const Action = union(enum) {
/// Sync with: ghostty_ipc_action_tag_e
pub const Key = enum(c_int) {
new_window,
toggle_quick_terminal,
test "ghostty.h Action.Key" {
try lib.checkGhosttyHEnum(Key, "GHOSTTY_IPC_ACTION_");

View File

@ -11,6 +11,7 @@ const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
const ssh = @import("ssh.zig");
const ssh_cache = @import("ssh_cache.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
@ -20,6 +21,7 @@ const crash_report = @import("crash_report.zig");
const show_face = @import("show_face.zig");
const boo = @import("boo.zig");
const new_window = @import("new_window.zig");
const toggle_quick_terminal = @import("toggle_quick_terminal.zig");
/// Special commands that can be invoked via CLI flags. These are all
/// invoked by using `+<action>` as a CLI flag. The only exception is
@ -46,6 +48,9 @@ pub const Action = enum {
/// List keybind actions
@"list-actions",
/// Wrap `ssh` to configure Ghostty terminal integration on remote hosts
ssh,
/// Manage SSH terminfo cache for automatic remote host setup
@"ssh-cache",
@ -73,6 +78,9 @@ pub const Action = enum {
// Use IPC to tell the running Ghostty to open a new window.
@"new-window",
// Use IPC to tell the running Ghostty to toggle the quick terminal.
@"toggle-quick-terminal",
pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) {
// If we see a "-e" and we haven't seen a command yet, then
// we are done looking for commands. This special case enables
@ -144,6 +152,7 @@ pub const Action = enum {
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"ssh-cache" => try ssh_cache.run(alloc),
.ssh => try ssh.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"explain-config" => try explain_config.run(alloc),
@ -152,6 +161,7 @@ pub const Action = enum {
.@"show-face" => try show_face.run(alloc),
.boo => try boo.run(alloc),
.@"new-window" => try new_window.run(alloc),
.@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc),
};
}
@ -184,6 +194,7 @@ pub const Action = enum {
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"ssh-cache" => ssh_cache.Options,
.ssh => ssh.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"explain-config" => explain_config.Options,
@ -192,6 +203,7 @@ pub const Action = enum {
.@"show-face" => show_face.Options,
.boo => boo.Options,
.@"new-window" => new_window.Options,
.@"toggle-quick-terminal" => toggle_quick_terminal.Options,
};
}
}

View File

@ -17,14 +17,6 @@ const MAX_CACHE_SIZE = 512 * 1024;
/// Path to a file where the cache is stored.
path: []const u8,
pub const DefaultPathError = Allocator.Error || error{
/// The general error that is returned for any filesystem error
/// that may have resulted in the XDG lookup failing.
XdgLookupFailed,
};
pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
/// Returns the default path for the cache for a given program.
///
/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`.
@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
pub fn defaultPath(
alloc: Allocator,
program: []const u8,
) DefaultPathError![]const u8 {
) ![]const u8 {
const state_dir: []const u8 = xdg.state(
alloc,
.{ .subdir = program },
@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void {
};
}
pub const AddResult = enum { added, updated };
pub const AddError = std.fs.Dir.MakeError ||
std.fs.Dir.StatFileError ||
std.fs.File.OpenError ||
std.fs.File.ChmodError ||
std.io.Reader.LimitedAllocError ||
FixupPermissionsError ||
ReadEntriesError ||
WriteCacheFileError ||
Error;
/// Add or update a hostname entry in the cache.
/// Returns AddResult.added for new entries or AddResult.updated for existing ones.
/// Add or update an entry in the cache, recording `timestamp` (Unix seconds).
/// The cache file is created if it doesn't exist with secure permissions (0600).
pub fn add(
self: DiskCache,
alloc: Allocator,
hostname: []const u8,
) AddError!AddResult {
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
key: []const u8,
timestamp: i64,
) !void {
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Create cache directory if needed
if (std.fs.path.dirname(self.path)) |dir| {
@ -107,58 +87,49 @@ pub fn add(
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) file.unlock();
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
// Add or update entry
const gop = try entries.getOrPut(hostname);
const result: AddResult = if (!gop.found_existing) add: {
const hostname_copy = try alloc.dupe(u8, hostname);
errdefer alloc.free(hostname_copy);
// Update the timestamp of an existing entry, or insert a new one. For a
// new entry, dupe both strings up front so a failed allocation never
// leaves a half-built slot (borrowed key, undefined value) for the
// `deinitEntries` defer to walk.
if (entries.getPtr(key)) |existing| {
existing.timestamp = timestamp;
} else {
const key_copy = try alloc.dupe(u8, key);
errdefer alloc.free(key_copy);
const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty");
errdefer alloc.free(terminfo_copy);
gop.key_ptr.* = hostname_copy;
gop.value_ptr.* = .{
.hostname = gop.key_ptr.*,
.timestamp = std.time.timestamp(),
try entries.put(key_copy, .{
.hostname = key_copy,
.timestamp = timestamp,
.terminfo_version = terminfo_copy,
};
break :add .added;
} else update: {
// Update timestamp for existing entry
gop.value_ptr.timestamp = std.time.timestamp();
break :update .updated;
};
});
}
try self.writeCacheFile(entries, null);
return result;
try self.writeCacheFile(entries);
}
pub const RemoveError = std.fs.File.OpenError ||
FixupPermissionsError ||
ReadEntriesError ||
WriteCacheFileError ||
Error;
/// Remove a hostname entry from the cache.
/// No error is returned if the hostname doesn't exist or the cache file is missing.
/// Remove an entry from the cache. Returns true if an entry was removed,
/// false if the key wasn't present (or the cache file is missing).
pub fn remove(
self: DiskCache,
alloc: Allocator,
hostname: []const u8,
) RemoveError!void {
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
key: []const u8,
) !bool {
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Open our file
const file = std.fs.openFileAbsolute(
self.path,
.{ .mode = .read_write },
) catch |err| switch (err) {
error.FileNotFound => return,
error.FileNotFound => return false,
else => return err,
};
defer file.close();
@ -167,7 +138,7 @@ pub fn remove(
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) file.unlock();
// Read existing entries
@ -175,27 +146,73 @@ pub fn remove(
defer deinitEntries(alloc, &entries);
// Remove the entry if it exists and ensure we free the memory
if (entries.fetchRemove(hostname)) |kv| {
const removed = if (entries.fetchRemove(key)) |kv| removed: {
assert(kv.key.ptr == kv.value.hostname.ptr);
alloc.free(kv.value.hostname);
alloc.free(kv.value.terminfo_version);
break :removed true;
} else false;
try self.writeCacheFile(entries);
return removed;
}
/// Remove all entries older than `max_age_s` seconds and return how many
/// were pruned. Returns zero (and nothing written) if the cache file is
/// missing.
pub fn prune(
self: DiskCache,
alloc: Allocator,
max_age_s: u64,
) !usize {
const file = std.fs.openFileAbsolute(
self.path,
.{ .mode = .read_write },
) catch |err| switch (err) {
error.FileNotFound => return 0,
else => return err,
};
defer file.close();
try fixupPermissions(file);
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) file.unlock();
// Read existing entries
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
// Drop expired entries from the map, then persist what remains.
const now = std.time.timestamp();
var expired: std.ArrayList([]const u8) = .empty;
defer expired.deinit(alloc);
var iter = entries.iterator();
while (iter.next()) |kv| {
const age_s = now -| kv.value_ptr.timestamp;
if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*);
}
for (expired.items) |key| {
const kv = entries.fetchRemove(key).?;
assert(kv.key.ptr == kv.value.hostname.ptr);
alloc.free(kv.value.hostname);
alloc.free(kv.value.terminfo_version);
}
try self.writeCacheFile(entries, null);
try self.writeCacheFile(entries);
return expired.items.len;
}
pub const ContainsError = std.fs.File.OpenError ||
ReadEntriesError ||
error{HostnameIsInvalid};
/// Check if a hostname exists in the cache.
/// Check if a key exists in the cache.
/// Returns false if the cache file doesn't exist.
pub fn contains(
self: DiskCache,
alloc: Allocator,
hostname: []const u8,
) ContainsError!bool {
if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
key: []const u8,
) !bool {
if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Open our file
const file = std.fs.openFileAbsolute(
@ -211,12 +228,10 @@ pub fn contains(
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
return entries.contains(hostname);
return entries.contains(key);
}
pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError);
fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
fn fixupPermissions(file: std.fs.File) !void {
// Windows does not support chmod
if (comptime builtin.os.tag == .windows) return;
@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
}
}
pub const WriteCacheFileError = std.fs.Dir.OpenError ||
std.fs.AtomicFile.InitError ||
std.fs.AtomicFile.FlushError ||
std.fs.AtomicFile.FinishError ||
Entry.FormatError ||
error{InvalidCachePath};
fn writeCacheFile(
self: DiskCache,
entries: std.StringHashMap(Entry),
expire_days: ?u32,
) WriteCacheFileError!void {
) !void {
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
const cache_basename = std.fs.path.basename(self.path);
@ -255,8 +262,6 @@ fn writeCacheFile(
var iter = entries.iterator();
while (iter.next()) |kv| {
// Only write non-expired entries
if (kv.value_ptr.isExpired(expire_days)) continue;
try kv.value_ptr.format(&atomic_file.file_writer.interface);
}
@ -299,12 +304,10 @@ pub fn deinitEntries(
entries.deinit();
}
pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError;
fn readEntries(
alloc: Allocator,
file: std.fs.File,
) ReadEntriesError!std.StringHashMap(Entry) {
) !std.StringHashMap(Entry) {
var reader = file.reader(&.{});
const content = try reader.interface.allocRemaining(
alloc,
@ -313,26 +316,35 @@ fn readEntries(
defer alloc.free(content);
var entries = std.StringHashMap(Entry).init(alloc);
errdefer deinitEntries(alloc, &entries);
var lines = std.mem.tokenizeScalar(u8, content, '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
const entry = Entry.parse(trimmed) orelse continue;
// Always allocate hostname first to avoid key pointer confusion
const hostname = try alloc.dupe(u8, entry.hostname);
errdefer alloc.free(hostname);
// Dupe both strings up front, before inserting, so the map never
// holds a half-built entry (a borrowed key or a freed/undefined
// value) for `deinitEntries` to walk if an allocation fails.
var hostname: ?[]u8 = try alloc.dupe(u8, entry.hostname);
errdefer if (hostname) |h| alloc.free(h);
var terminfo: ?[]u8 = try alloc.dupe(u8, entry.terminfo_version);
errdefer if (terminfo) |t| alloc.free(t);
const gop = try entries.getOrPut(hostname);
const gop = try entries.getOrPut(hostname.?);
if (!gop.found_existing) {
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
// New entry: transfer both copies to the map.
gop.value_ptr.* = .{
.hostname = hostname,
.hostname = hostname.?,
.timestamp = entry.timestamp,
.terminfo_version = terminfo_copy,
.terminfo_version = terminfo.?,
};
hostname = null;
terminfo = null;
} else {
// Don't need the copy since entry already exists
alloc.free(hostname);
// Duplicate key: the map keeps its existing key, so free ours.
alloc.free(hostname.?);
hostname = null;
// Handle duplicate entries - keep newer timestamp
if (entry.timestamp > gop.value_ptr.timestamp) {
@ -340,13 +352,15 @@ fn readEntries(
if (!std.mem.eql(
u8,
gop.value_ptr.terminfo_version,
entry.terminfo_version,
terminfo.?,
)) {
alloc.free(gop.value_ptr.terminfo_version);
const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
gop.value_ptr.terminfo_version = terminfo_copy;
gop.value_ptr.terminfo_version = terminfo.?;
terminfo = null;
}
}
if (terminfo) |t| alloc.free(t);
terminfo = null;
}
}
@ -354,7 +368,7 @@ fn readEntries(
}
// Supports both standalone hostnames and user@hostname format
fn isValidCacheKey(key: []const u8) bool {
pub fn isValidCacheKey(key: []const u8) bool {
if (key.len == 0) return false;
// Check for user@hostname format
@ -452,33 +466,23 @@ test "disk cache operations" {
const path = try tmp.dir.realpathAlloc(alloc, "cache");
defer alloc.free(path);
// Setup our cache
// Setup our cache. Adding the same key twice exercises both the new
// and existing-entry paths.
const cache: DiskCache = .{ .path = path };
try testing.expectEqual(
AddResult.added,
try cache.add(alloc, "example.com"),
);
try testing.expectEqual(
AddResult.updated,
try cache.add(alloc, "example.com"),
);
try testing.expect(
try cache.contains(alloc, "example.com"),
);
try cache.add(alloc, "example.com", std.time.timestamp());
try cache.add(alloc, "example.com", std.time.timestamp());
try testing.expect(try cache.contains(alloc, "example.com"));
// List
var entries = try cache.list(alloc);
deinitEntries(alloc, &entries);
// Remove
try cache.remove(alloc, "example.com");
try testing.expect(
!(try cache.contains(alloc, "example.com")),
);
try testing.expectEqual(
AddResult.added,
try cache.add(alloc, "example.com"),
);
// Remove reports that it removed the entry, and a second remove of the
// same key reports nothing to remove.
try testing.expect(try cache.remove(alloc, "example.com"));
try testing.expect(!try cache.remove(alloc, "example.com"));
try testing.expect(!(try cache.contains(alloc, "example.com")));
try cache.add(alloc, "example.com", std.time.timestamp());
}
test "disk cache cleans up temp files" {
@ -494,8 +498,8 @@ test "disk cache cleans up temp files" {
defer alloc.free(cache_path);
const cache: DiskCache = .{ .path = cache_path };
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com"));
try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org"));
try cache.add(alloc, "example.com", std.time.timestamp());
try cache.add(alloc, "example.org", std.time.timestamp());
// Verify only the cache file exists and no temp files left behind
var count: usize = 0;
@ -507,6 +511,170 @@ test "disk cache cleans up temp files" {
try testing.expectEqual(1, count);
}
test "disk cache prune" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
defer alloc.free(tmp_path);
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
defer alloc.free(cache_path);
const cache: DiskCache = .{ .path = cache_path };
// Back-date one entry an hour old and one 100 days old.
const day = std.time.s_per_day;
const hour = std.time.s_per_hour;
const now = std.time.timestamp();
try cache.add(alloc, "recent.com", now - hour);
try cache.add(alloc, "old.com", now - 100 * day);
// Prune entries older than 90 days: only old.com goes.
try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day));
try testing.expect(try cache.contains(alloc, "recent.com"));
try testing.expect(!try cache.contains(alloc, "old.com"));
// Pruning again removes nothing.
try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day));
// Sub-day granularity: a 30-minute max age prunes the hour-old entry.
try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min));
try testing.expect(!try cache.contains(alloc, "recent.com"));
}
test "disk cache prune missing file" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
defer alloc.free(tmp_path);
const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
defer alloc.free(cache_path);
const cache: DiskCache = .{ .path = cache_path };
try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30));
}
test "disk cache reads duplicate keys" {
const testing = std.testing;
const alloc = testing.allocator;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
// Exercise readEntries' found_existing branch: replace the existing
// key with the updated entry and ensure (via testing.allocator) that
// we don't double-free or leak.
{
var file = try tmp.dir.createFile("cache", .{});
defer file.close();
var buf: [256]u8 = undefined;
var file_writer = file.writer(&buf);
try file_writer.interface.writeAll(
"example.com|100|xterm-ghostty\nexample.com|200|xterm-newer\n",
);
try file_writer.interface.flush();
}
const path = try tmp.dir.realpathAlloc(alloc, "cache");
defer alloc.free(path);
const cache: DiskCache = .{ .path = path };
var entries = try cache.list(alloc);
defer deinitEntries(alloc, &entries);
try testing.expectEqual(@as(u32, 1), entries.count());
const entry = entries.get("example.com").?;
try testing.expectEqual(@as(i64, 200), entry.timestamp);
try testing.expectEqualStrings("xterm-newer", entry.terminfo_version);
}
test "disk cache reads survive allocation failure" {
const testing = std.testing;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
// Exercise a populated cache containing a duplicate key to ensure
// that we hit all of the possible allocation behaviors below.
{
var file = try tmp.dir.createFile("cache", .{});
defer file.close();
var buf: [256]u8 = undefined;
var file_writer = file.writer(&buf);
try file_writer.interface.writeAll(
"a.com|100|xterm-ghostty\n" ++
"b.com|100|xterm-ghostty\n" ++
"c.com|100|xterm-ghostty\n" ++
"a.com|200|xterm-newer\n",
);
try file_writer.interface.flush();
}
const path = try tmp.dir.realpathAlloc(testing.allocator, "cache");
defer testing.allocator.free(path);
const cache: DiskCache = .{ .path = path };
// Fail the Nth allocation for every N until the read completes. The
// FailingAllocator is backed by testing.allocator so we also ensure
// that we don't double-free or leak; this can only completely succeed
// or fail with OutOfMemory.
var fail_index: usize = 0;
while (true) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(
testing.allocator,
.{ .fail_index = fail_index },
);
const alloc = failing.allocator();
if (cache.list(alloc)) |entries_const| {
var entries = entries_const;
deinitEntries(alloc, &entries);
// Reached a run with no induced failure: every path covered.
if (!failing.has_induced_failure) break;
} else |err| {
try testing.expectEqual(error.OutOfMemory, err);
}
}
}
test "disk cache add survives allocation failure" {
const testing = std.testing;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
defer testing.allocator.free(tmp_path);
const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" });
defer testing.allocator.free(path);
const cache: DiskCache = .{ .path = path };
// Fail the Nth allocation for every N until add completes. A failed add
// must not leak or leave a half-built map entry. The FailingAllocator
// is backed by testing.allocator to catch either. Each iteration starts
// from a clean cache file.
var fail_index: usize = 0;
while (true) : (fail_index += 1) {
std.fs.cwd().deleteFile(path) catch {};
var failing = std.testing.FailingAllocator.init(
testing.allocator,
.{ .fail_index = fail_index },
);
const alloc = failing.allocator();
if (cache.add(alloc, "user@example.com", 100)) |_| {
if (!failing.has_induced_failure) break;
} else |err| {
try testing.expectEqual(error.OutOfMemory, err);
}
}
}
test isValidHost {
const testing = std.testing;

View File

@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void {
);
}
pub fn isExpired(self: Entry, expire_days_: ?u32) bool {
const expire_days = expire_days_ orelse return false;
const now = std.time.timestamp();
const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day);
return age_days > expire_days;
}
test "cache entry expiration" {
const testing = std.testing;
const now = std.time.timestamp();
const fresh_entry: Entry = .{
.hostname = "test.com",
.timestamp = now - std.time.s_per_day, // 1 day old
.terminfo_version = "xterm-ghostty",
};
try testing.expect(!fresh_entry.isExpired(90));
const old_entry: Entry = .{
.hostname = "old.com",
.timestamp = now - (std.time.s_per_day * 100), // 100 days old
.terminfo_version = "xterm-ghostty",
};
try testing.expect(old_entry.isExpired(90));
// Test never-expire case
try testing.expect(!old_entry.isExpired(null));
}
test "cache entry expiration exact boundary" {
const testing = std.testing;
const now = std.time.timestamp();
// Exactly at expiration boundary
const boundary_entry: Entry = .{
.hostname = "example.com",
.timestamp = now - (std.time.s_per_day * 30),
.terminfo_version = "xterm-ghostty",
};
try testing.expect(!boundary_entry.isExpired(30));
try testing.expect(boundary_entry.isExpired(29));
}
test "cache entry expiration large timestamp" {
const testing = std.testing;
const now = std.time.timestamp();
const boundary_entry: Entry = .{
.hostname = "example.com",
.timestamp = now + (std.time.s_per_day * 30),
.terminfo_version = "xterm-ghostty",
};
try testing.expect(!boundary_entry.isExpired(30));
}
test "cache entry parsing valid formats" {
const testing = std.testing;

635
src/cli/ssh.zig Normal file
View File

@ -0,0 +1,635 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const cli_args = @import("args.zig");
const diagnostics = @import("diagnostics.zig");
const Action = @import("ghostty.zig").Action;
const DiskCache = @import("ssh_cache.zig").DiskCache;
const internal_os = @import("../os/main.zig");
const ghostty_terminfo = @import("../terminfo/main.zig").ghostty;
const log = std.log.scoped(.ssh);
const usage =
\\Usage: ghostty +ssh [flags] [--] <ssh args...>
\\
\\Flags:
\\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true.
\\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true.
\\ --cache[=bool] Use the terminfo install cache. Default: true.
\\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH.
\\ --verbose Print +ssh status lines to stderr.
\\ --help Show full help.
\\
\\ssh flags and the destination go after +ssh's own flags (or after `--`).
\\
;
pub const Options = struct {
/// Set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
/// Maps to the `ssh-env` shell integration feature.
@"forward-env": bool = true,
/// Maps to the `ssh-terminfo` shell integration feature.
terminfo: bool = true,
/// When false, both cache read and write are bypassed.
cache: bool = true,
/// The wrapped `ssh` binary.
/// `/`-containing values are treated as paths; otherwise resolved via PATH.
ssh: []const u8 = "ssh",
/// When true, print verbose output to stderr.
verbose: bool = false,
/// Arguments passed through to `ssh` verbatim. Populated by
/// `parseManuallyHook` when we reach the first non-flag argument (or
/// an explicit `--`).
_ssh_args: std.ArrayList([]const u8) = .empty,
/// Enables arg parsing diagnostics so unknown flags become
/// diagnostics rather than fatal errors.
_diagnostics: diagnostics.DiagnosticList = .{},
pub fn deinit(self: *Options) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
/// Enables `-h` and `--help` to work.
pub fn help(_: Options) !void {
return Action.help_error;
}
/// Manual parse hook. For each argument:
/// - If it's a literal `--`, consume everything after it as ssh
/// args and stop parsing.
/// - If it doesn't start with `--`, this is the start of the ssh
/// argv. Consume this arg and everything after as ssh args and
/// stop parsing.
/// - Otherwise (a `--foo` arg), return true so the generic parser
/// handles it as one of our own flags.
pub fn parseManuallyHook(
self: *Options,
alloc: Allocator,
arg: []const u8,
iter: anytype,
) Allocator.Error!bool {
if (std.mem.eql(u8, arg, "--")) {
while (iter.next()) |rest| {
try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
}
return false;
}
if (!std.mem.startsWith(u8, arg, "--")) {
try self._ssh_args.append(alloc, try alloc.dupe(u8, arg));
while (iter.next()) |rest| {
try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
}
return false;
}
return true;
}
};
/// Wrap `ssh` to automatically configure Ghostty terminal integration on
/// remote hosts.
///
/// Any arguments that aren't recognized as `+ssh` flags are passed to
/// the real `ssh` binary unchanged. You can use `--` as an explicit
/// disambiguator if needed, though it's almost never required: `ssh`
/// has no long flags, and `+ssh` defines no short flags, so there's
/// nothing to collide.
///
/// This is typically called via Ghostty's shell integration. When
/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`,
/// each shell defines an `ssh` function that runs:
///
/// ghostty +ssh <flags> -- "$@"
///
/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g.
/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell
/// integration.
///
/// `+ssh` performs up to two pieces of setup before launching `ssh`:
///
/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to
/// `xterm-256color` and requests `SendEnv` forwarding of
/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the
/// remote shell can still detect that it's running inside Ghostty.
/// The remote `sshd_config` must list these in `AcceptEnv` for
/// forwarding to succeed.
///
/// 2. **Terminfo install** (`--terminfo`). On the first connection to a
/// given destination, installs Ghostty's terminfo entry on the remote
/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a
/// shared `ControlMaster` connection. Successful installs are cached
/// (see `ghostty +ssh-cache`) so subsequent connections skip this
/// step. When terminfo is successfully installed or already cached,
/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`.
///
/// If `--terminfo` install fails (e.g. `tic` not available on the
/// remote, filesystem permissions), a warning is logged and the
/// connection continues with `TERM=xterm-256color`.
///
/// Flags:
///
/// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment
/// forwarding. Default: `true`.
///
/// * `--terminfo=<bool>`: Enable automatic terminfo install on first
/// connection. Default: `true`.
///
/// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`.
/// When `false`, both the cache read (skip-if-installed) and the
/// cache write (record-on-success) are bypassed, and every
/// connection performs the install. To one-shot reinstall a single
/// host while keeping the cache in use, prefer `ghostty +ssh-cache
/// --remove=<host>` followed by a normal connection.
///
/// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the
/// first `ssh` found on `PATH`.
///
/// * `--verbose`: Print +ssh status lines to stderr, and surface
/// remote stderr during the terminfo install.
///
/// Examples:
///
/// # Basic invocation using defaults:
/// ghostty +ssh user@example.com
///
/// # Forward Ghostty env vars but skip the terminfo install:
/// ghostty +ssh --terminfo=false user@example.com
///
/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged:
/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com
///
/// # Use `--` explicitly if your ssh args might collide with our flags:
/// ghostty +ssh -- --some-rare-ssh-arg user@example.com
///
/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection
/// and management, see `ghostty +ssh-cache`.
///
/// Available since: 1.4.0
pub fn run(alloc_gpa: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
{
var iter = try cli_args.argsIterator(alloc_gpa);
defer iter.deinit();
try cli_args.parse(Options, alloc_gpa, &opts, &iter);
}
var stderr_buffer: [1024]u8 = undefined;
var stderr_file: std.fs.File = .stderr();
var stderr_writer = stderr_file.writer(&stderr_buffer);
const stderr = &stderr_writer.interface;
// Any diagnostic from the arg parser is an unknown flag or bad
// value. Reject loudly silently forwarding `--typo` to ssh would
// produce confusing downstream errors.
if (!opts._diagnostics.empty()) {
for (opts._diagnostics.items()) |diag| {
if (diag.key.len > 0) {
stderr.print(
"Error: unknown flag `--{s}`.\n",
.{diag.key},
) catch {};
} else {
stderr.print("Error: {s}\n", .{diag.message}) catch {};
}
}
stderr.print("\n{s}", .{usage}) catch {};
stderr.flush() catch {};
return 2;
}
const result = runInner(alloc_gpa, &opts, stderr);
stderr.flush() catch {};
return result;
}
fn runInner(
gpa: Allocator,
opts: *const Options,
stderr: *std.Io.Writer,
) !u8 {
var arena = ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
if (opts._ssh_args.items.len == 0) {
try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage});
return 2;
}
const session: struct {
term: []const u8,
to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null,
} = session: {
if (!opts.terminfo) break :session .{ .term = "xterm-256color" };
const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse {
warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{});
break :session .{ .term = "xterm-256color" };
};
const cache: ?DiskCache = if (opts.cache) cache: {
const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| {
warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err});
break :session .{ .term = "xterm-256color" };
};
break :cache .{ .path = path };
} else null;
if (cache) |c| {
if (c.contains(alloc, dest) catch false) {
verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest});
break :session .{ .term = "xterm-ghostty" };
} else {
verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest});
}
} else {
verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest});
}
stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {};
stderr.flush() catch {};
installRemoteTerminfo(alloc, opts, stderr) catch |err| {
warnPrint(stderr, "failed to install terminfo: {}", .{err});
break :session .{ .term = "xterm-256color" };
};
break :session .{
.term = "xterm-ghostty",
.to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null,
};
};
// Build the full argv: [ssh, ...our opts, ...user args]
const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: {
const set_term = try std.fmt.allocPrint(
alloc,
"SetEnv=TERM={s}",
.{session.term},
);
break :env_opts &.{
"-o", set_term,
"-o", "SendEnv=COLORTERM",
"-o", "SendEnv=TERM_PROGRAM",
"-o", "SendEnv=TERM_PROGRAM_VERSION",
};
} else &.{};
const argv = try std.mem.concat(alloc, []const u8, &.{
&.{opts.ssh},
env_opts,
opts._ssh_args.items,
});
verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
const exit_code = childExec(alloc, argv) catch |err| {
try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err });
return 1;
};
verbosePrint(opts, stderr, "exit: {d}", .{exit_code});
// Attempt to cache (if needed) on a successful ssh execution.
if (exit_code == 0) if (session.to_cache) |entry| {
if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| {
verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
} else |err| {
log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
}
};
return exit_code;
}
/// Log to `.ssh` and, if `--verbose`, also print to stderr.
fn verbosePrint(
opts: *const Options,
stderr: *std.Io.Writer,
comptime fmt: []const u8,
args: anytype,
) void {
log.debug(fmt, args);
if (!opts.verbose) return;
stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return;
stderr.flush() catch return;
}
/// Log a warning and also print a `Warning: <msg>` line to stderr.
fn warnPrint(
stderr: *std.Io.Writer,
comptime fmt: []const u8,
args: anytype,
) void {
log.warn(fmt, args);
stderr.print("Warning: " ++ fmt ++ "\n", args) catch return;
stderr.flush() catch return;
}
/// Space-joined items, formattable as `{f}`.
const Joined = struct {
items: []const []const u8,
pub fn format(self: Joined, writer: *std.Io.Writer) !void {
for (self.items, 0..) |a, i| {
if (i > 0) try writer.writeByte(' ');
try writer.writeAll(a);
}
}
test {
const testing = std.testing;
var buf: [128]u8 = undefined;
{
var w: std.Io.Writer = .fixed(&buf);
try w.print("{f}", .{Joined{ .items = &.{} }});
try testing.expectEqualStrings("", buf[0..w.end]);
}
{
var w: std.Io.Writer = .fixed(&buf);
try w.print("{f}", .{Joined{ .items = &.{"only"} }});
try testing.expectEqualStrings("only", buf[0..w.end]);
}
{
var w: std.Io.Writer = .fixed(&buf);
try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }});
try testing.expectEqualStrings("a b c", buf[0..w.end]);
}
}
};
fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void {
switch (term) {
.Exited => |rc| if (rc != 0) {
log.warn("{s} exited with non-zero status: {d}", .{ label, rc });
return error.ChildFailed;
},
else => {
log.warn("{s} terminated abnormally: {}", .{ label, term });
return error.ChildFailed;
},
}
}
/// Run `ssh -G <args>` and parse the output for `user` and `hostname`.
/// Returns the resolved `user@hostname`, or null if the destination
/// could not be resolved.
fn resolveDestination(
alloc: Allocator,
ssh: []const u8,
args: []const []const u8,
) ?[]const u8 {
const argv = std.mem.concat(alloc, []const u8, &.{
&.{ ssh, "-G" },
args,
}) catch return null;
const result = std.process.Child.run(.{
.allocator = alloc,
.argv = argv,
}) catch |err| {
log.warn("ssh -G spawn failed: {}", .{err});
return null;
};
checkExit(result.term, "ssh -G") catch return null;
return parseDestination(alloc, result.stdout);
}
/// Parse `ssh -G` output for `user` and `hostname` and return the
/// formatted `user@hostname`. Returns null if either key is missing
/// or formatting fails.
fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 {
var user: []const u8 = "";
var host: []const u8 = "";
var it = std.mem.tokenizeScalar(u8, stdout, '\n');
while (it.next()) |line| {
const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue;
const key = line[0..space];
const value = line[space + 1 ..];
if (std.mem.eql(u8, key, "user")) {
user = value;
} else if (std.mem.eql(u8, key, "hostname")) {
host = value;
}
if (user.len > 0 and host.len > 0) break;
}
if (user.len == 0) {
log.warn("ssh -G output missing user", .{});
return null;
}
if (host.len == 0) {
log.warn("ssh -G output missing hostname", .{});
return null;
}
return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null;
}
/// Install Ghostty's terminfo on the remote host over a short-lived SSH
/// ControlMaster connection. The master tears down with the client
/// (`ControlPersist=no`) so no socket lingers.
fn installRemoteTerminfo(
alloc: Allocator,
opts: *const Options,
stderr: *std.Io.Writer,
) !void {
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
try ghostty_terminfo.encode(&buf.writer);
const terminfo = buf.written();
// ControlPath is in TMPDIR with a short, random basename. ssh uses
// ControlPath as the bind address for a Unix domain socket; macOS
// limits sockaddr_un.sun_path to ~104 bytes, so keeping the path
// short leaves margin.
const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-");
const control_path_opt = try std.fmt.allocPrint(
alloc,
"ControlPath={s}",
.{control_path},
);
// Under --verbose, let remote stderr through (the `tic` step is
// the most common failure source) and inherit ssh's stderr so it
// reaches the user's terminal. Other steps stay quiet either way.
const remote_script = if (opts.verbose)
\\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
\\command -v tic >/dev/null 2>&1 || exit 1
\\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0
\\exit 1
else
\\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
\\command -v tic >/dev/null 2>&1 || exit 1
\\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
\\exit 1
;
// Set up an SSH ControlMaster scoped to this single install:
// - ControlMaster=yes makes our client also act as the master,
// so `infocmp | ssh tic` runs over a single connection.
// - ControlPersist=no tears the master down when our client
// exits; no socket lingers on the remote side.
const argv = try std.mem.concat(alloc, []const u8, &.{
&.{opts.ssh},
&.{
"-o", "ControlMaster=yes",
"-o", "ControlPersist=no",
"-o", control_path_opt,
},
opts._ssh_args.items,
&.{remote_script},
});
verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
var child: std.process.Child = .init(argv, alloc);
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Ignore;
child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore;
child.spawn() catch |err| {
log.warn("terminfo install spawn failed: {}", .{err});
return error.InstallFailed;
};
if (child.stdin) |stdin| {
stdin.writeAll(terminfo) catch {};
stdin.close();
child.stdin = null;
}
const term = child.wait() catch |err| {
log.warn("terminfo install wait failed: {}", .{err});
return error.InstallFailed;
};
checkExit(term, "terminfo install") catch return error.InstallFailed;
}
/// Returns `128 + signum` for signal-killed children, matching shell convention.
fn childExec(alloc: Allocator, argv: []const []const u8) !u8 {
var child: std.process.Child = .init(argv, alloc);
child.stdin_behavior = .Inherit;
child.stdout_behavior = .Inherit;
child.stderr_behavior = .Inherit;
try child.spawn();
const term = try child.wait();
return switch (term) {
.Exited => |rc| rc,
.Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))),
.Stopped, .Unknown => 1,
};
}
fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void {
var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line);
defer iter.deinit();
try cli_args.parse(Options, alloc, opts, &iter);
}
test "parseManuallyHook: bare destination starts ssh args" {
const testing = std.testing;
var opts: Options = .{};
defer opts.deinit();
try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com");
try testing.expectEqual(false, opts.terminfo);
try testing.expectEqual(true, opts.@"forward-env");
try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len);
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]);
}
test "parseManuallyHook: short ssh flags pass through verbatim" {
const testing = std.testing;
var opts: Options = .{};
defer opts.deinit();
try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com");
try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len);
try testing.expectEqualStrings("-p", opts._ssh_args.items[0]);
try testing.expectEqualStrings("2222", opts._ssh_args.items[1]);
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]);
}
test "parseManuallyHook: explicit -- separator" {
const testing = std.testing;
var opts: Options = .{};
defer opts.deinit();
try parseTestArgs(
testing.allocator,
&opts,
"--verbose -- --some-rare-ssh-arg user@example.com",
);
try testing.expectEqual(true, opts.verbose);
try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len);
try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]);
try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]);
}
test "parseDestination: typical ssh -G output" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const stdout =
\\user alice
\\hostname example.com
\\port 22
\\identityfile ~/.ssh/id_ed25519
\\
;
const result = parseDestination(arena.allocator(), stdout);
try testing.expectEqualStrings("alice@example.com", result.?);
}
test "parseDestination: hostname before user" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const stdout =
\\hostname example.com
\\port 22
\\user alice
\\
;
const result = parseDestination(arena.allocator(), stdout);
try testing.expectEqualStrings("alice@example.com", result.?);
}
test "parseDestination: missing hostname returns null" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const stdout = "user alice\nport 22\n";
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
}
test "parseDestination: missing user returns null" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const stdout = "hostname example.com\nport 22\n";
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
}
test "parseDestination: empty input returns null" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), ""));
}
test "parseDestination: IPv6 hostname" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const stdout = "user alice\nhostname ::1\n";
const result = parseDestination(arena.allocator(), stdout);
try testing.expectEqualStrings("alice@::1", result.?);
}

View File

@ -3,6 +3,7 @@ const fs = std.fs;
const Allocator = std.mem.Allocator;
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
const Duration = @import("../config.zig").Config.Duration;
pub const Entry = @import("ssh-cache/Entry.zig");
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
@ -10,8 +11,7 @@ pub const Options = struct {
clear: bool = false,
add: ?[]const u8 = null,
remove: ?[]const u8 = null,
host: ?[]const u8 = null,
@"expire-days": ?u32 = null,
prune: ?Duration = null,
pub fn deinit(self: *Options) void {
_ = self;
@ -25,27 +25,36 @@ pub const Options = struct {
/// Manage the SSH terminfo cache for automatic remote host setup.
///
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
/// Ghostty automatically installs its terminfo on remote hosts. This command
/// manages the cache of successful installations to avoid redundant uploads.
/// The `+ssh` action installs Ghostty's terminfo on remote hosts and records
/// each success in this cache so it doesn't re-upload on later connections.
/// (`+ssh` runs automatically from the shell integration when
/// `shell-integration-features` includes `ssh-terminfo`.) This command
/// inspects and maintains that cache.
///
/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
/// Entries older than the expiration period are automatically removed during cache
/// operations. By default, entries never expire.
/// The cache stores destinations (a hostname or user@hostname) along with
/// timestamps.
///
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
/// If multiple are specified, one of the actions will be executed but
/// it isn't guaranteed which one. This is entirely unsafe so you should split
/// multiple actions into separate commands.
/// A positional destination queries the cache: `user@hostname` shows that
/// exact entry, while a bare `hostname` shows every cached entry for that
/// host regardless of user. With no destination and no action, the entire
/// cache is listed. A query that matches nothing exits 1.
///
/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be
/// specified, and not together with a positional destination; combining them
/// is an error.
///
/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`,
/// `y`) and removes every entry older than it, e.g. `--prune=30d`,
/// `--prune=6h`, `--prune=1y`.
///
/// Examples:
/// ghostty +ssh-cache # List all cached hosts
/// ghostty +ssh-cache --host=example.com # Check if host is cached
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
/// ghostty +ssh-cache --clear # Clear entire cache
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
/// ghostty +ssh-cache # List all cached destinations
/// ghostty +ssh-cache user@example.com # Show that destination
/// ghostty +ssh-cache example.com # Show all users on that host
/// ghostty +ssh-cache --add=user@example.com # Manually add a destination
/// ghostty +ssh-cache --remove=user@example.com # Remove a destination
/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
/// ghostty +ssh-cache --clear # Clear entire cache
pub fn run(alloc_gpa: Allocator) !u8 {
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
{
var iter = try args.argsIterator(alloc_gpa);
defer iter.deinit();
try args.parse(Options, alloc_gpa, &opts, &iter);
}
var stdout_buffer: [1024]u8 = undefined;
var stdout_file: std.fs.File = .stdout();
var stdout_writer = stdout_file.writer(&stdout_buffer);
@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 {
var stderr_writer = stderr_file.writer(&stderr_buffer);
const stderr = &stderr_writer.interface;
const result = runInner(alloc, opts, stdout, stderr);
// The cache is queried by a positional destination (`user@host` or a
// bare `host`). `args.parse` rejects non-`--` tokens, so we lift the
// positional out here and parse only the remaining flags. `--host=X`
// is accepted as a deprecated spelling of the positional (it was the
// original shipped flag name).
var query: ?[]const u8 = null;
var flags: std.ArrayList([]const u8) = .empty;
{
var iter = try args.argsIterator(alloc_gpa);
defer iter.deinit();
while (iter.next()) |arg| {
const is_host_flag = std.mem.startsWith(u8, arg, "--host=");
if (is_host_flag) {
try stderr.print(
"Warning: --host is deprecated; pass the destination " ++
"directly, e.g. `ghostty +ssh-cache {s}`.\n",
.{arg["--host=".len..]},
);
}
const dest: ?[]const u8 = if (is_host_flag)
arg["--host=".len..]
else if (!std.mem.startsWith(u8, arg, "-"))
arg
else
null;
if (dest) |d| {
if (query != null) {
try stderr.print(
"Error: only one destination may be specified.\n",
.{},
);
stderr.flush() catch {};
return 2;
}
query = try alloc.dupe(u8, d);
} else {
try flags.append(alloc, try alloc.dupe(u8, arg));
}
}
}
{
var iter = args.sliceIterator(flags.items);
args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) {
error.InvalidField => {
try stderr.print("Error: unknown flag.\n", .{});
stderr.flush() catch {};
return 2;
},
error.InvalidValue, error.ValueRequired => {
try stderr.print("Error: invalid flag value.\n", .{});
stderr.flush() catch {};
return 2;
},
else => return err,
};
}
const result = runInner(alloc, opts, query, stdout, stderr);
// Flushing *shouldn't* fail but...
stdout.flush() catch {};
@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 {
pub fn runInner(
alloc: Allocator,
opts: Options,
query: ?[]const u8,
stdout: *std.Io.Writer,
stderr: *std.Io.Writer,
) !u8 {
// At most one action may be specified, and a query (positional
// destination) is itself an action.
const action_count =
@as(usize, @intFromBool(opts.clear)) +
@intFromBool(opts.add != null) +
@intFromBool(opts.remove != null) +
@intFromBool(opts.prune != null) +
@intFromBool(query != null);
if (action_count > 1) {
try stderr.print(
"Error: only one of a destination, --clear, --add, --remove, " ++
"or --prune may be specified.\n",
.{},
);
return 2;
}
// Setup our disk cache to the standard location
const cache_path = try DiskCache.defaultPath(alloc, "ghostty");
const cache: DiskCache = .{ .path = cache_path };
if (opts.clear) {
try cache.clear();
try stdout.print("Cache cleared.\n", .{});
return 0;
}
if (opts.add) |host| {
const result = cache.add(alloc, host) catch |err| switch (err) {
DiskCache.Error.HostnameIsInvalid => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
DiskCache.Error.CacheIsLocked => {
try stderr.print("Error: Cache is busy, try again\n", .{});
return 1;
if (opts.add) |dest| {
cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) {
error.InvalidCacheKey => {
try stderr.print(
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
.{dest},
);
return 2;
},
else => {
try stderr.print(
"Error: Unable to add '{s}' to cache. Error: {}\n",
.{ host, err },
.{ dest, err },
);
return 1;
},
};
switch (result) {
.added => try stdout.print("Added '{s}' to cache.\n", .{host}),
.updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}),
}
return 0;
}
if (opts.remove) |host| {
cache.remove(alloc, host) catch |err| switch (err) {
DiskCache.Error.HostnameIsInvalid => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
DiskCache.Error.CacheIsLocked => {
try stderr.print("Error: Cache is busy, try again\n", .{});
return 1;
if (opts.remove) |dest| {
const removed = cache.remove(alloc, dest) catch |err| switch (err) {
error.InvalidCacheKey => {
try stderr.print(
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
.{dest},
);
return 2;
},
else => {
try stderr.print(
"Error: Unable to remove '{s}' from cache. Error: {}\n",
.{ host, err },
.{ dest, err },
);
return 1;
},
};
try stdout.print("Removed '{s}' from cache.\n", .{host});
// Silence on success; a no-op removal is an error (exit 1).
if (!removed) {
try stderr.print("Error: '{s}' is not in the cache.\n", .{dest});
return 1;
}
return 0;
}
if (opts.host) |host| {
const cached = cache.contains(alloc, host) catch |err| switch (err) {
error.HostnameIsInvalid => {
try stderr.print("Error: Invalid hostname format '{s}'\n", .{host});
try stderr.print("Expected format: hostname or user@hostname\n", .{});
return 1;
},
else => {
try stderr.print(
"Error: Unable to check host '{s}' in cache. Error: {}\n",
.{ host, err },
);
return 1;
},
};
if (cached) {
try stdout.print(
"'{s}' has Ghostty terminfo installed.\n",
.{host},
if (opts.prune) |max_age| {
const max_age_s = max_age.duration / std.time.ns_per_s;
if (max_age_s == 0) {
try stderr.print(
"Error: --prune requires a duration of at least one second.\n",
.{},
);
return 0;
} else {
try stdout.print(
"'{s}' does not have Ghostty terminfo installed.\n",
.{host},
);
return 1;
return 2;
}
const pruned = cache.prune(alloc, max_age_s) catch |err| {
try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err});
return 1;
};
try stdout.print("Pruned cache entries: {d}\n", .{pruned});
return 0;
}
// Default action: list all hosts
var entries = try cache.list(alloc);
defer DiskCache.deinitEntries(alloc, &entries);
// A positional query filters the listing: an exact `user@host` match,
// or every entry on a bare `host`.
if (query) |q| {
if (!DiskCache.isValidCacheKey(q)) {
try stderr.print(
"Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
.{q},
);
return 2;
}
var matches: std.StringHashMap(Entry) = .init(alloc);
defer matches.deinit();
var iter = entries.iterator();
while (iter.next()) |kv| {
const key = kv.key_ptr.*;
if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*);
}
if (matches.count() == 0) return 1;
try listEntries(alloc, &matches, stdout);
return 0;
}
// List all destinations by default.
try listEntries(alloc, &entries, stdout);
return 0;
}
@ -187,10 +272,7 @@ fn listEntries(
entries: *const std.StringHashMap(Entry),
writer: *std.Io.Writer,
) !void {
if (entries.count() == 0) {
try writer.print("No hosts in cache.\n", .{});
return;
}
if (entries.count() == 0) return;
// Sort entries by hostname for consistent output
var items: std.ArrayList(Entry) = .empty;
@ -207,22 +289,200 @@ fn listEntries(
}
}.lessThan);
try writer.print("Cached hosts ({d}):\n", .{items.items.len});
const now = std.time.timestamp();
// Align the timestamp column by padding destinations to the widest.
var widest: usize = 0;
for (items.items) |entry| {
const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day);
if (age_days == 0) {
try writer.print(" {s} (today)\n", .{entry.hostname});
} else if (age_days == 1) {
try writer.print(" {s} (yesterday)\n", .{entry.hostname});
} else {
try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days });
}
widest = @max(widest, entry.hostname.len);
}
const now = std.time.timestamp();
for (items.items) |entry| {
try writer.print("{s}", .{entry.hostname});
try writer.splatByteAll(' ', widest - entry.hostname.len + 2);
var iso_buf: [20]u8 = undefined;
var age_buf: [32]u8 = undefined;
try writer.print("{s} ({s})\n", .{
formatTimestamp(&iso_buf, entry.timestamp),
relativeAge(&age_buf, now, entry.timestamp),
});
}
}
/// Whether a cache `key` matches a positional `query`. A `user@host` query
/// (containing `@`) matches one exact key; a bare `host` query matches every
/// key on that host regardless of user, comparing against the key's host
/// component (everything after its first `@`, or the whole key if userless).
fn matchesQuery(key: []const u8, query: []const u8) bool {
if (std.mem.indexOfScalar(u8, query, '@') != null) {
return std.mem.eql(u8, key, query);
}
const at = std.mem.indexOfScalar(u8, key, '@');
const host = if (at) |i| key[i + 1 ..] else key;
return std.mem.eql(u8, host, query);
}
test matchesQuery {
const testing = std.testing;
// Exact user@host: only the identical key.
try testing.expect(matchesQuery("user@example.com", "user@example.com"));
try testing.expect(!matchesQuery("root@example.com", "user@example.com"));
try testing.expect(!matchesQuery("example.com", "user@example.com"));
// Bare host: every key on that host, plus a keyless entry for it.
try testing.expect(matchesQuery("user@example.com", "example.com"));
try testing.expect(matchesQuery("root@example.com", "example.com"));
try testing.expect(matchesQuery("example.com", "example.com"));
try testing.expect(!matchesQuery("user@other.com", "example.com"));
}
/// Format a Unix timestamp as an ISO-8601 UTC string
/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes.
/// Out-of-range input is clamped so this can't crash on a garbage cache line.
fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 {
// Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch`
// accumulates the year in a `u16` (panics beyond that), and the buffer
// only fits a 4-digit year.
const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799));
const epoch = std.time.epoch;
const epoch_secs: epoch.EpochSeconds = .{ .secs = secs };
const day = epoch_secs.getEpochDay();
const year_day = day.calculateYearDay();
const month_day = year_day.calculateMonthDay();
const ds = epoch_secs.getDaySeconds();
return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
year_day.year,
month_day.month.numeric(),
month_day.day_index + 1,
ds.getHoursIntoDay(),
ds.getMinutesIntoHour(),
ds.getSecondsIntoMinute(),
}) catch unreachable;
}
test formatTimestamp {
const testing = std.testing;
var buf: [20]u8 = undefined;
try testing.expectEqualStrings(
"2026-05-05T22:49:33Z",
formatTimestamp(&buf, 1778021373),
);
// Epoch.
try testing.expectEqualStrings(
"1970-01-01T00:00:00Z",
formatTimestamp(&buf, 0),
);
// Out-of-range inputs clamp instead of overflowing the [20]u8 /
// panicking inside std: negatives floor at the epoch, huge values cap
// at the last second of year 9999.
try testing.expectEqualStrings(
"1970-01-01T00:00:00Z",
formatTimestamp(&buf, -5),
);
try testing.expectEqualStrings(
"9999-12-31T23:59:59Z",
formatTimestamp(&buf, std.math.maxInt(i64)),
);
}
/// Format the age of `timestamp` (relative to `now`, both Unix seconds)
/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s
/// unit vocabulary but keeps only the single largest unit for scannability.
/// A non-positive age (timestamp at or after `now`) is "now".
fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 {
// Saturating so a garbage timestamp can't overflow; clamp at 0 so a
// future timestamp becomes a zero age rather than going negative.
const age: u64 = @intCast(@max(0, now -| timestamp));
if (age == 0) return "now";
// Round down to the largest unit that fits, so Duration.format emits
// only that unit (e.g. 19d -> 2w, 90m -> 1h).
const units = [_]u64{
365 * std.time.s_per_day, // y
std.time.s_per_week, // w
std.time.s_per_day, // d
std.time.s_per_hour, // h
std.time.s_per_min, // m
1, // s
};
const unit = for (units) |u| {
if (age >= u) break u;
} else 1;
// Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g.
// hugely negative, timestamp otherwise yields an age near i64-max).
const max_age = std.math.maxInt(u64) / std.time.ns_per_s;
const rounded = @min(age, max_age) / unit * unit;
const d: Duration = .{ .duration = rounded * std.time.ns_per_s };
return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable;
}
test relativeAge {
const testing = std.testing;
var buf: [32]u8 = undefined;
const now: i64 = 2_000_000_000; // fixed reference
const min = std.time.s_per_min;
const hour = std.time.s_per_hour;
const day = std.time.s_per_day;
// Out-of-range timestamps don't crash: a huge future one saturates to
// a non-positive age ("now"); a negative one is a large but real age.
try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64)));
try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100));
// A huge age (garbage timestamp) saturates the ns conversion instead of
// overflowing; it must not crash and must fit the buffer.
try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago"));
// Future timestamp (clock skew) and same-instant read "now".
try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100));
try testing.expectEqualStrings("now", relativeAge(&buf, now, now));
// Only the single largest unit is kept (smaller units rounded away).
try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30));
try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min));
try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m
try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour));
try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h
try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day));
try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w
}
test {
_ = DiskCache;
_ = Entry;
}
test "runInner rejects multiple actions" {
const testing = std.testing;
const alloc = testing.allocator;
var stdout: std.Io.Writer.Allocating = .init(alloc);
defer stdout.deinit();
var stderr: std.Io.Writer.Allocating = .init(alloc);
defer stderr.deinit();
// The check runs before any cache access, so it never touches disk.
const code = try runInner(alloc, .{
.add = "example.com",
.remove = "other.com",
}, null, &stdout.writer, &stderr.writer);
try testing.expectEqual(@as(u8, 2), code);
try testing.expectEqualStrings("", stdout.written());
try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
// A positional query is itself an action: query + a flag conflicts.
stderr.clearRetainingCapacity();
const code2 = try runInner(alloc, .{
.clear = true,
}, "example.com", &stdout.writer, &stderr.writer);
try testing.expectEqual(@as(u8, 2), code2);
try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
}

View File

@ -0,0 +1,62 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Action = @import("../cli.zig").ghostty.Action;
const apprt = @import("../apprt.zig");
pub const Options = struct {
/// If set, connect to a custom instance of Ghostty.
class: ?[:0]const u8 = null,
pub fn deinit(self: *Options) void {
self.* = undefined;
}
/// Enables "-h" and "--help" to work.
pub fn help(self: Options) !void {
_ = self;
return Action.help_error;
}
};
/// The `+toggle-quick-terminal` command will use native platform IPC to toggle
/// the quick terminal in a running instance of Ghostty.
///
/// If the `--class` flag is not set, the command will try and connect to the
/// default running Ghostty instance. Otherwise it will contact a Ghostty
/// instance configured with the given `class`.
///
/// On GTK, D-Bus activation must be properly configured. Ghostty does not need
/// to be running, as D-Bus will handle launching a new instance if it is not
/// already running.
///
/// Only supported on GTK.
///
/// Flags:
///
/// * `--class=<class>`: If set, connect to a custom instance of Ghostty.
/// The class must be a valid GTK application ID.
///
/// Available since: 1.4.0
pub fn run(alloc: Allocator) !u8 {
var buf: [256]u8 = undefined;
var stderr_writer = std.fs.File.stderr().writer(&buf);
const stderr = &stderr_writer.interface;
if (apprt.App.performIpc(
alloc,
.detect,
.toggle_quick_terminal,
{},
) catch |err| switch (err) {
error.IPCFailed => {
return 1;
},
else => {
try stderr.print("Sending the IPC failed: {}\n", .{err});
return 1;
},
}) return 0;
try stderr.print("+toggle-quick-terminal is not supported on this platform.\n", .{});
return 1;
}

View File

@ -49,6 +49,7 @@ const string = @import("string.zig");
const terminal = struct {
const CursorStyle = @import("../terminal/cursor.zig").Style;
const color = @import("../terminal/color.zig");
const selection_codepoints = @import("../terminal/selection_codepoints.zig");
const style = @import("../terminal/style.zig");
const x11_color = @import("../terminal/x11_color.zig");
};
@ -757,12 +758,12 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
/// The null character (U+0000) is always treated as a boundary and does not
/// need to be included in this configuration.
///
/// Default: `` \t'"│`|:;,()[]{}<>$ ``
/// Default: ``\t '"│`|:;,()[]{}<>$``
///
/// To add or remove specific characters, you can set this to a custom value.
/// For example, to treat semicolons as part of words:
///
/// selection-word-chars = " \t'\"│`|:,()[]{}<>$"
/// selection-word-chars = "\t '\"│`|:,()[]{}<>$"
///
/// Available since: 1.3.0
@"selection-word-chars": SelectionWordChars = .{},
@ -2890,9 +2891,16 @@ keybind: Keybinds = .{},
/// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb"
/// ```
///
/// There are some additional special values that can be specified for
/// command-palette-entry:
///
/// * `command-palette-entry=clear` will clear all command entries. Warning: this
/// removes ALL entries up to this point, including the default
/// entries. Available since: 1.4.0
///
/// By default, the command palette is preloaded with most actions that might
/// be useful in an interactive setting yet do not have easily accessible or
/// memorizable shortcuts. The default entries can be cleared by setting this
/// memorizable shortcuts. The default entries can be restored by setting this
/// setting to an empty value:
///
/// ```ini
@ -6177,32 +6185,8 @@ pub const RepeatableString = struct {
pub const SelectionWordChars = struct {
const Self = @This();
/// Default boundary characters: ` \t'"│`|:;,()[]{}<>$`
const default_codepoints = [_]u21{
0, // null
' ', // space
'\t', // tab
'\'', // single quote
'"', // double quote
'│', // U+2502 box drawing
'`', // backtick
'|', // pipe
':', // colon
';', // semicolon
',', // comma
'(', // left paren
')', // right paren
'[', // left bracket
']', // right bracket
'{', // left brace
'}', // right brace
'<', // less than
'>', // greater than
'$', // dollar
};
/// The parsed codepoints. Always includes null (U+0000) at index 0.
codepoints: []const u21 = &default_codepoints,
codepoints: []const u21 = &terminal.selection_codepoints.default_word_boundaries,
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
const value = input orelse return error.ValueRequired;
@ -8777,6 +8761,13 @@ pub const RepeatableCommand = struct {
// Unset or empty input clears the list
const input = input_ orelse "";
if (input.len == 0) {
log.info("config has 'command-palette-entry =', using default entries", .{});
try self.init(alloc);
return;
}
if (std.mem.eql(u8, input, "clear")) {
log.info("config has 'command-palette-entry = clear', all command entries cleared", .{});
self.value.clearRetainingCapacity();
self.value_c.clearRetainingCapacity();
return;
@ -8888,8 +8879,11 @@ pub const RepeatableCommand = struct {
try testing.expectEqualStrings("Baz", list.value.items[3].title);
try testing.expectEqualStrings("Raspberry Pie", list.value.items[3].description);
try list.parseCLI(alloc, "");
try list.parseCLI(alloc, "clear");
try testing.expectEqual(@as(usize, 0), list.value.items.len);
try list.parseCLI(alloc, "");
try testing.expectEqual(inputpkg.command.defaults.len, list.value.items.len);
}
test "RepeatableCommand formatConfig empty" {
@ -9004,7 +8998,7 @@ pub const RepeatableCommand = struct {
try list.parseCLI(alloc, "title:Foo,action:ignore");
try testing.expectEqual(@as(usize, 1), list.cval().len);
try list.parseCLI(alloc, "");
try list.parseCLI(alloc, "clear");
try testing.expectEqual(@as(usize, 0), list.cval().len);
}
};

View File

@ -860,25 +860,26 @@ pub fn SplitTree(comptime V: type) type {
var sp = try result.spatial(gpa);
defer sp.deinit(gpa);
// Get the ratio of the split relative to the full grid.
const full_ratio = full_ratio: {
// Our scale is the amount we need to multiply our individual
// ratio by to get the full ratio. Its actually a ratio on its
// own but I'm trying to avoid that word: its the ratio of
// our spatial width/height to the total.
const scale = switch (layout) {
.horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width,
.vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height,
};
const current = result.nodes[parent_handle.idx()].split.ratio;
break :full_ratio current * scale;
// Our scale is the amount we need to divide our ratio delta by to
// get a delta relative to the split, not the entire grid.
// Its actually a ratio on its own but I'm trying to avoid that word:
// its the ratio of our spatial width/height to the total.
const scale = switch (layout) {
.horizontal => sp.slots[parent_handle.idx()].width / sp.slots[0].width,
.vertical => sp.slots[parent_handle.idx()].height / sp.slots[0].height,
};
// Set the final new ratio, clamping it to [0, 1]
// If the split has spatial width/height 0, resizing by a percentage
// of the total grid size doesn't make sense.
if (scale == 0) return result;
// Adjust the old split ratio by the scaled ratio delta.
const new_ratio = result.nodes[parent_handle.idx()].split.ratio + (ratio / scale);
// Set the new ratio, clamping it to [0, 1]
result.resizeInPlace(
parent_handle,
@min(@max(full_ratio + ratio, 0), 1),
@min(@max(new_ratio, 0), 1),
);
return result;
}
@ -2172,6 +2173,155 @@ test "SplitTree: resize" {
}
}
test "SplitTree: resize nested split" {
const testing = std.testing;
const alloc = testing.allocator;
var v1: TestTree.View = .{ .label = "A" };
var t1: TestTree = try .init(alloc, &v1);
defer t1.deinit();
var v2: TestTree.View = .{ .label = "B" };
var t2: TestTree = try .init(alloc, &v2);
defer t2.deinit();
var v3: TestTree.View = .{ .label = "C" };
var t3: TestTree = try .init(alloc, &v3);
defer t3.deinit();
// A | B vertical
var splitAB = try t1.split(
alloc,
.root, // at root
.down, // split down
0.5,
&t2, // insert t2
);
defer splitAB.deinit();
var splitBC = try splitAB.split(
alloc,
at: {
var it = splitAB.iterator();
break :at while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "B")) {
break entry.handle;
}
} else return error.NotFound;
},
.down, // split down
0.5,
&t3, // insert t3
);
defer splitBC.deinit();
{
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(splitBC, .formatDiagram)});
defer alloc.free(str);
try testing.expectEqualStrings(str,
\\+---+
\\| |
\\| |
\\| A |
\\| |
\\+---+
\\+---+
\\| B |
\\+---+
\\+---+
\\| C |
\\+---+
\\
);
}
// Resize
{
var resized = try splitBC.resize(
alloc,
at: {
var it = splitBC.iterator();
break :at while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "B")) {
break entry.handle;
}
} else return error.NotFound;
},
.vertical, // resize down
0.125,
);
defer resized.deinit();
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
defer alloc.free(str);
try testing.expectEqualStrings(str,
\\+---+
\\| |
\\| |
\\| |
\\| |
\\| |
\\| A |
\\| |
\\| |
\\| |
\\| |
\\+---+
\\+---+
\\| |
\\| |
\\| |
\\| B |
\\| |
\\| |
\\| |
\\+---+
\\+---+
\\| C |
\\+---+
\\
);
}
// Resize the other direction (negative ratio)
{
var resized = try splitBC.resize(
alloc,
at: {
var it = splitBC.iterator();
break :at while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "B")) {
break entry.handle;
}
} else return error.NotFound;
},
.vertical, // resize up
-0.0833,
);
defer resized.deinit();
const str = try std.fmt.allocPrint(alloc, "{f}", .{std.fmt.alt(resized, .formatDiagram)});
defer alloc.free(str);
try testing.expectEqualStrings(str,
\\+---+
\\| |
\\| |
\\| |
\\| A |
\\| |
\\| |
\\| |
\\+---+
\\+---+
\\| B |
\\+---+
\\+---+
\\| |
\\| |
\\| C |
\\| |
\\+---+
\\
);
}
}
test "SplitTree: clone empty tree" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -462,7 +462,8 @@ fn mouseTable(
{
const left_click_point: terminal.point.Coordinate = pt: {
const p = surface_mouse.left_click_pin orelse break :pt .{};
const p = surface_mouse.selection_gesture.validatedLeftClickPin(&t.screens) orelse
break :pt .{};
const pt = t.screens.active.pages.pointFromPin(
.active,
p.*,
@ -495,8 +496,8 @@ fn mouseTable(
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text(
"(%dpx, %dpx)",
@as(u32, @intFromFloat(surface_mouse.left_click_xpos)),
@as(u32, @intFromFloat(surface_mouse.left_click_ypos)),
@as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_xpos)),
@as(u32, @intFromFloat(surface_mouse.selection_gesture.left_click_ypos)),
);
}
}

View File

@ -5,6 +5,7 @@ const types = @import("types.zig");
const unionpkg = @import("union.zig");
pub const allocator = @import("allocator.zig");
pub const Buffer = types.Buffer;
pub const Enum = enumpkg.Enum;
pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum;
pub const String = types.String;

View File

@ -11,3 +11,9 @@ pub const String = extern struct {
};
}
};
pub const Buffer = extern struct {
ptr: ?[*]u8 = null,
cap: usize = 0,
len: usize = 0,
};

View File

@ -209,6 +209,8 @@ comptime {
@export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" });
@export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" });
@export(&c.formatter_free, .{ .name = "ghostty_formatter_free" });
@export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" });
@export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" });
@export(&c.render_state_new, .{ .name = "ghostty_render_state_new" });
@export(&c.render_state_update, .{ .name = "ghostty_render_state_update" });
@export(&c.render_state_get, .{ .name = "ghostty_render_state_get" });
@ -239,7 +241,27 @@ comptime {
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
@export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" });
@export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" });
@export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" });
@export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" });
@export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" });
@export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" });
@export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" });
@export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" });
@export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" });
@export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" });
@export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" });
@export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" });
@export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" });
@export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" });
@export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" });
@export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" });
@export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" });
@export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" });
@export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" });
@export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" });
@export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" });
@export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" });
@export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" });
@export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" });
@export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" });
@ -262,6 +284,11 @@ comptime {
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
@export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" });
@export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" });
@export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" });
@export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" });
@export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" });
@export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" });
@export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" });
@export(&c.build_info, .{ .name = "ghostty_build_info" });
@export(&c.type_json, .{ .name = "ghostty_type_json" });
@export(&c.alloc_alloc, .{ .name = "ghostty_alloc" });

View File

@ -58,4 +58,5 @@ pub const locales = [_][:0]const u8{
"vi",
"kk",
"be",
"eu",
};

View File

@ -9,13 +9,11 @@ const log = std.log.scoped(.@"os-open");
/// Open a URL in the default handling application.
///
/// Any output on stderr is logged as a warning in the application logs.
/// Output on stdout is ignored. The allocator is used to buffer the
/// log output and may allocate from another thread.
/// Output on stdout is ignored.
///
/// This function is purposely simple for the sake of providing
/// some portable way to open URLs. If you are implementing an
/// apprt for Ghostty, you should consider doing something special-cased
/// for your platform.
/// This function is purposely simple for the sake of providing some portable
/// way to open URLs. If you are implementing an apprt for Ghostty, you should
/// consider doing something special-cased for your platform.
pub fn open(
alloc: Allocator,
kind: apprt.action.OpenUrl.Kind,
@ -44,14 +42,16 @@ pub fn open(
else => @compileError("unsupported OS"),
};
// Pipe stdout/stderr so we can collect output from the command.
// This must be set before spawning the process.
exe.stdout_behavior = .Pipe;
// Ignore anything from stdout. This must be set before spawning the
// process.
exe.stdout_behavior = .Ignore;
// Pipe stderr so we can log the stderr from the command. This must be set
// before spawning the process.
exe.stderr_behavior = .Pipe;
// In the snap on Linux the launcher exports LD_LIBRARY_PATH pointing at
// the snap's bundled libraries. Leaking this into child process can
// can be problematic, so let's drop it from the env
// the snap's bundled libraries. Leaking this into child process can can be
// problematic, so let's drop it from the env
var snap_env: std.process.EnvMap = if (comptime build_config.snap) blk: {
var env = try std.process.getEnvMap(alloc);
env.remove("LD_LIBRARY_PATH");
@ -64,34 +64,34 @@ pub fn open(
// quickly.
try exe.spawn();
// Create a thread that handles collecting output and reaping
// the process. This is done in a separate thread because SOME
// open implementations block and some do not. It's easier to just
// spawn a thread to handle this so that we never block.
const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe });
// Create a thread that handles collecting output and reaping the process.
// This is done in a separate thread because SOME open implementations block
// and some do not. It's easier to just spawn a thread to handle this so
// that we never block.
const thread = try std.Thread.spawn(.{}, openThread, .{exe});
thread.detach();
}
fn openThread(alloc: Allocator, exe_: std.process.Child) !void {
// 50 KiB is the default value used by std.process.Child.run and should
// be enough to get the output we care about.
const output_max_size = 50 * 1024;
var stdout: std.ArrayListUnmanaged(u8) = .{};
var stderr: std.ArrayListUnmanaged(u8) = .{};
defer {
stdout.deinit(alloc);
stderr.deinit(alloc);
}
fn openThread(exe_: std.process.Child) void {
// Copy the exe so it is non-const. This is necessary because wait()
// requires a mutable reference and we can't have one as a thread
// param.
var exe = exe_;
try exe.collectOutput(alloc, &stdout, &stderr, output_max_size);
_ = try exe.wait();
// If we have any stderr output we log it. This makes it easier for
// users to debug why some open commands may not work as expected.
if (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items});
if (exe.stderr) |stderr| {
var buffer: [256]u8 = undefined;
var stream = stderr.readerStreaming(&buffer);
const reader = &stream.interface;
while (true) {
const line = reader.takeDelimiterExclusive('\n') catch |outer| switch (outer) {
error.EndOfStream => break,
error.ReadFailed => break,
error.StreamTooLong => reader.take(buffer.len) catch |inner| switch (inner) {
error.ReadFailed => break,
error.EndOfStream => break,
},
};
log.warn("open stderr={s}", .{line});
}
}
_ = exe.wait() catch {};
}

View File

@ -360,10 +360,16 @@ fn drainMailbox(self: *Thread) !void {
// Visibility affects our QoS class
self.setQosClass();
// If we became visible then we immediately trigger a draw.
// We don't need to update frame data because that should
// still be happening.
if (v) self.drawFrame(false);
// If we became visible then we immediately rebuild cells
// (renderCallback skips updateFrame while invisible) and draw.
if (v) {
self.renderer.updateFrame(
self.state,
self.flags.cursor_blink_visible,
) catch |err|
log.warn("error rendering on visibility regain err={}", .{err});
self.drawFrame(false);
}
// Notify the renderer so it can update any state.
self.renderer.setVisible(v);
@ -606,6 +612,10 @@ fn renderCallback(
return .disarm;
};
// If we're not visible there's no point spending CPU rebuilding cells
// we'll catch up when the .visible mailbox message flips us back on.
if (!t.flags.visible) return .disarm;
// Update our frame data
t.renderer.updateFrame(
t.state,

View File

@ -61,12 +61,6 @@ if (eq $E:TERM "xterm-ghostty") {
}
```
The [Elvish](https://elv.sh) shell integration is supported by
the community and is not officially supported by Ghostty. We distribute
it for ease of access and use but do not provide support for it.
If you experience issues with the Elvish shell integration, I welcome
any contributions to fix them. Thank you!
### Fish
For [Fish](https://fishshell.com/), Ghostty prepends to the

View File

@ -115,71 +115,16 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
fi
# SSH Integration
#
# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration
# feature flags into command options.
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
function ssh() {
builtin local ssh_term ssh_opts
ssh_term="xterm-256color"
ssh_opts=()
# Configure environment variables for remote session
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then
ssh_opts+=(-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION")
fi
# Install terminfo on remote host if needed
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then
builtin local ssh_user ssh_hostname
while IFS=' ' read -r ssh_key ssh_value; do
case "$ssh_key" in
user) ssh_user="$ssh_value" ;;
hostname) ssh_hostname="$ssh_value" ;;
esac
[[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break
done < <(builtin command ssh -G "$@" 2>/dev/null)
if [[ -n "$ssh_hostname" ]]; then
builtin local ssh_target="${ssh_user}@${ssh_hostname}"
# Check if terminfo is already cached
if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then
ssh_term="xterm-ghostty"
elif builtin command -v infocmp >/dev/null 2>&1; then
builtin local ssh_terminfo ssh_cpath_dir ssh_cpath
ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null)
if [[ -n "$ssh_terminfo" ]]; then
builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$"
ssh_cpath="$ssh_cpath_dir/socket"
if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" '
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
' 2>/dev/null; then
ssh_term="xterm-ghostty"
ssh_opts+=(-o "ControlPath=$ssh_cpath")
# Cache successful installation
"$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true
else
builtin echo "Warning: Failed to install terminfo." >&2
fi
else
builtin echo "Warning: Could not generate terminfo data." >&2
fi
else
builtin echo "Warning: ghostty command not available for cache management." >&2
fi
fi
fi
# Execute SSH with TERM environment variable
TERM="$ssh_term" COLORTERM=truecolor builtin command ssh "${ssh_opts[@]}" "$@"
builtin local -a flags
flags=()
[[ "$GHOSTTY_SHELL_FEATURES" != *ssh-env* ]] && flags+=(--forward-env=false)
[[ "$GHOSTTY_SHELL_FEATURES" != *ssh-terminfo* ]] && flags+=(--terminfo=false)
"$GHOSTTY_BIN_DIR/ghostty" +ssh "${flags[@]}" -- "$@"
}
fi

View File

@ -76,80 +76,20 @@
(external sudo) $@args
}
# SSH Integration
#
# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration
# feature flags into command options.
fn ssh-integration {|@args|
var ssh-term = "xterm-256color"
var ssh-opts = []
# Configure environment variables for remote session
if (has-value $features ssh-env) {
set ssh-opts = (conj $ssh-opts ^
-o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION")
var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty"
var flags = []
if (not (has-value $features ssh-env)) {
set flags = (conj $flags --forward-env=false)
}
if (has-value $features ssh-terminfo) {
var ssh-user = ""
var ssh-hostname = ""
# Parse ssh config
for line [((external ssh) -G $@args)] {
var parts = [(str:fields $line)]
if (> (count $parts) 1) {
var ssh-key = $parts[0]
var ssh-value = $parts[1]
if (eq $ssh-key user) {
set ssh-user = $ssh-value
} elif (eq $ssh-key hostname) {
set ssh-hostname = $ssh-value
}
if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) {
break
}
}
}
if (not-eq $ssh-hostname "") {
var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty"
var ssh-target = $ssh-user"@"$ssh-hostname
# Check if terminfo is already cached
if (bool ?($ghostty +ssh-cache --host=$ssh-target)) {
set ssh-term = "xterm-ghostty"
} elif (has-external infocmp) {
var ssh-terminfo = ((external infocmp) -0 -x xterm-ghostty 2>/dev/null | slurp)
if (not-eq $ssh-terminfo "") {
echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2
use os
var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*")
var ssh-cpath = $ssh-cpath-dir"/socket"
if (bool ?(echo $ssh-terminfo | (external ssh) $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args '
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
' 2>/dev/null)) {
set ssh-term = "xterm-ghostty"
set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath)
# Cache successful installation
$ghostty +ssh-cache --add=$ssh-target >/dev/null
} else {
echo "Warning: Failed to install terminfo." >&2
}
} else {
echo "Warning: Could not generate terminfo data." >&2
}
} else {
echo "Warning: ghostty command not available for cache management." >&2
}
}
}
with [E:TERM = $ssh-term E:COLORTERM = truecolor] {
(external ssh) $@ssh-opts $@args
if (not (has-value $features ssh-terminfo)) {
set flags = (conj $flags --terminfo=false)
}
$ghostty +ssh $@flags -- $@args
}
defer {

View File

@ -120,84 +120,17 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
end
# SSH Integration
#
# Wrap `ssh` with `ghostty +ssh` and translate the shell-integration
# feature flags into command options.
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
if contains ssh-env $features; or contains ssh-terminfo $features
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
set -l ssh_term xterm-256color
set -l ssh_opts
# Configure environment variables for remote session
if contains ssh-env $features
set -a ssh_opts -o "SendEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION"
end
# Install terminfo on remote host if needed
if contains ssh-terminfo $features
set -l ssh_user
set -l ssh_hostname
for line in (command ssh -G $argv 2>/dev/null)
set -l parts (string split ' ' -- $line)
if test (count $parts) -ge 2
switch $parts[1]
case user
set ssh_user $parts[2]
case hostname
set ssh_hostname $parts[2]
end
if test -n "$ssh_user"; and test -n "$ssh_hostname"
break
end
end
end
if test -n "$ssh_hostname"
set -l ssh_target "$ssh_user@$ssh_hostname"
# Check if terminfo is already cached
if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1
set ssh_term xterm-ghostty
else if command -q infocmp
set -l ssh_terminfo
set -l ssh_cpath_dir
set -l ssh_cpath
set ssh_terminfo "$(infocmp -0 -x xterm-ghostty 2>/dev/null)"
if test -n "$ssh_terminfo"
echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2
set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random))
set ssh_cpath "$ssh_cpath_dir/socket"
if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv '
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
' 2>/dev/null
set ssh_term xterm-ghostty
set -a ssh_opts -o "ControlPath=$ssh_cpath"
# Cache successful installation
if test -x "$GHOSTTY_BIN_DIR/ghostty"
"$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true
end
else
echo "Warning: Failed to install terminfo." >&2
end
else
echo "Warning: Could not generate terminfo data." >&2
end
else
echo "Warning: ghostty command not available for cache management." >&2
end
end
end
# Execute SSH with TERM environment variable
TERM="$ssh_term" COLORTERM=truecolor command ssh $ssh_opts $argv
set -l flags
contains ssh-env $features; or set -a flags --forward-env=false
contains ssh-terminfo $features; or set -a flags --terminfo=false
"$GHOSTTY_BIN_DIR/ghostty" +ssh $flags -- $argv
end
end

Some files were not shown because too many files have changed in this diff Show More