Its moniker has been `libghostty-internal` for *quite* a while now among
maintainers but that has never really been clarified for the public aside
from a couple comments on discussions. Judging by how many people still
try to vibe their way into making this work for their purposes, I think
we should clear this up once and for all.
Fixes#13719
The Kitty graphics protocol requires retransmitting data for a specific
image ID to delete the previous image and all of its placements.
Ghostty instead preserved the placement count and map when replacing
image data. Repeated `a=T` commands therefore added one anonymous
placement per frame and retained its tracked pin.
Spec:
https://sw.kovidgoyal.net/kitty/graphics-protocol/#display-images-on-screen
Fixes#13719
The Kitty graphics protocol requires retransmitting data for a
specific image ID to delete the previous image and all of its
placements.
Ghostty instead preserved the placement count and map when replacing image
data. Repeated `a=T` commands therefore added one anonymous placement per
frame and retained its tracked pin.
Spec: https://sw.kovidgoyal.net/kitty/graphics-protocol/#display-images-on-screen
Startup optimizations for macOS! Highlights:
* Process exec to visible window: **15% reduction, ~193ms to ~165ms.**
* Time to first rendered frame: **27% reduction, ~126ms to ~92ms.**
* Zig startup time goes from **20ms to ~5ms**, the remainder is
AppKit/Swift stuff.
> [!NOTE]
>
> "Time to first rendered frame?" I measured the time between global
init start to the first Metal callback saying that a frame was
completed/drawn. This is faster than when it is _presented_ because we
can create an IOSurfaceLayer and draw to it before AppKit finishes its
startup and shows the window. But, the good news is this means that when
the window is shown, the frame is already drawn!
See individual commits for speeds, but a summary below:
1. **Resolve Sentry directories on the init thread, not startup thread
(~3-4ms).** Sentry init already ran on a thread, but directory
resolution happened on the main thread first, and on macOS that calls
`NSFileManager URLForDirectory:` which is slow as shit.
2. **Initialize the TIS keymap lazily (~7ms).** The keymap is only
needed once keyboard events flow. If AppKit isn't warmed up, this is
SLOW. Defer setup until its needed.
3. **Warm up the font registry and Metal on background threads (~7ms+
off the first surface).** The first CoreText query initializes the
system font database (~7ms) and the first Metal device/queue/pipeline
use pays framework init and shader compilation costs. `App.create` now
spawns a detached warmup thread per subsystem so this overlaps config
load, AppKit launch, and window creation. First font grid init went from
~4.5ms to ~1.1ms, renderer init from ~6.8ms to ~1.5ms.
4. **Look up Apple Color Emoji by exact name.** We know exactly which
font we want, so skip the system-wide `CTFontCollection` matching
(~312us to ~13us).
5. **Cache unified logging loggers per scope.** We created and released
an `os_log_t` on every log call. I actually had a comment saying this is
slow but probably won't matter. Well, we log a lot on startup, and this
actually mattered.
## Warmup Threads
As a note, some of the biggest speedups are by using "warmup" threads.
These are one-time launched threads on system start that basically just
"touch" the relevant frameworks (CoreText/Metal). The initial touching
of these frameworks has a ton of cost associated with them (and they're
thread-safe), so we can shave off a bunch of time by just touching them
in the background.
This sets up a race between our own startup needing it and these warmup
threads, but in every case I measured, the warmup threads win.
## Linux
All the optimizations here focused really on slow macOS APIs. I plan on
measuring on Linux, but nothing here should slow it down.
**AI usage:** Fable was used for this one to find the issues, help
perform the measurements, and draft commit messages by splitting up my
work. I wrote the code, then edited the commit messages. This PR message
is fully hand-written.
windowDidLoad undoes macOS automatic window tabbing by inspecting
window.tabGroup. Accessing tabGroup on a fresh window materializes
AppKit's tab group machinery, which takes ~15-20ms and is on the
critical path of every window creation, including the first window at
app launch.
AppKit only auto-tabs a fresh window when the system tabbing
preference is "always": the tab bar "+" button goes through
newWindowForTab which we intercept and route through our own tab
logic, so it never auto-tabs. Guard the check on
NSWindow.userTabbingPreference == .always so everyone else skips the
tab group materialization entirely.
Measured on macOS (Apple Silicon) during app launch via the startup
timeline instrumentation:
windowDidLoad tab group check: 17.8ms -> ~0ms
main() -> window visible: median ~173ms -> ~165ms (n=7)
Measured on macOS (Apple Silicon) during app launch, via a startup
timeline instrumented across the Swift app and libghostty:
config apply, errors step: 35.5ms -> 0.1ms
main() -> first frame rendered: ~126ms -> ~93ms
main() -> window visible: ~193ms -> ~173ms
The embedded apprt App init created the keyboard layout keymap
eagerly, which requires talking to the text input system (TIS). The
first TIS call in a process is slow: 6.6ms measured inside
ghostty_app_new during app launch (up to ~30ms in a cold process).
The keymap is only used for keyboard layout queries (option-as-alt
detection, layout change reload), which happen once keyboard events
are flowing. By then AppKit has already warmed TIS and the call is
effectively free (~0.2us measured warm). So initialize the keymap
lazily on first use. If the layout changes before the keymap was ever
created, reload is a no-op since lazy init picks up the current
layout.
Measured on macOS (Apple Silicon) with local timing instrumentation
during app launch:
embedded app init before: ~6.7ms (keymap 6614us)
embedded app init after: ~60us (config clone only)
Extend the Metal portion of the startup warmup thread to also create
(and discard) a command queue and build (and discard) the shader
pipelines for both pixel formats we may use (which one is used
depends on the blending config). The first command queue for a device
and the first render pipeline state creations pay one-time driver
setup and shader compilation costs; once warm, the real creations
during surface initialization hit driver and OS caches.
Measured on macOS (Apple Silicon) with local timing instrumentation
during app launch, first surface renderer initialization:
queue creation: 717us -> 93us
pipeline builds: 1023us -> 347us
renderer init total: 2777us -> 1466us
The first Metal device query in a process (MTLCopyAllDevices) takes
multiple milliseconds; once the framework is warm, subsequent queries
are effectively free (measured ~15ms cold, ~1us warm in isolation).
This cost was paid during the first surface's renderer
initialization, on the critical path to the first window.
Measured on macOS (Apple Silicon) with local timing instrumentation
during app launch, first surface renderer initialization:
GraphicsAPI.init before: 4227us (device query ~3.5ms)
GraphicsAPI.init after: ~900us (device query 20-25us)
The first CoreText font query in a process initializes the system
font database, which takes multiple milliseconds (~7ms measured in an
isolated process; 2-4ms observed inside Ghostty startup). This cost
was previously paid during the first surface's font grid
initialization, on the critical path to the first window.
App.create can now spawn a background thread that performs the warmup.
The Apple Color Emoji fallback font was discovered with the generic
discovery path, which builds a CTFontCollection and runs system-wide
font matching. Since we know the exact font we want, we can look it
up directly with CTFontCreateWithName instead.
The logFn for macOS unified logging created and released an os_log_t
logger on every single log call. Loggers are now cached per log scope for
the process lifetime via an atomic pointer (a creation race wastes at most
one create).
Measured on macOS (Apple Silicon) with local timing instrumentation
during app launch, the version-info logging block in global.init:
before: 1070us-2629us
after: 858us-1319us
Sentry initialization already ran on a separate thread, but the cache
and state directory resolution happened on the main thread before
spawning it. On macOS the cache dir resolution calls NSFileManager
URLForDirectory:inDomain:appropriateForURL:create:error: which takes
multiple milliseconds and was the single largest cost in global.init.
All directory resolution now happens on the init thread.
before: 2967us-4018us
after: 30us-70us (env map snapshot + thread spawn)
global.init total drops from ~3.4-5.0ms to ~0.4-1.0ms.
This shrinks the binary size of libghostty-vt by **16% on aarch64 macOS
and 22% on x86_64 Linux**. It also shrinks the in-memory footprint by
~256KB per thread + ~20KB per app. All benchmarks remain the same, no
speedups or slowdowns.
Each commit message explains an individual tactic used, but to
summarize:
1. **No stack traces in release panic handlers (~160KB).** This requires
the Zig stack unwind and symbolication logic. I don't think this makes
sense in an embedded library because the embedder should handle this.
2. **An alternate `std.Io` implementation called `TinyIo` (~100KB to
200KB).** See later... since this is the big one.
3. **Disable recursive Parser.Action logging (~35KB).** We now only log
the top-level fields of a Parser.Action, which lowers the amount of
`std.fmt` codegen significantly.
All sizes above are aarch64 macOS and x86_64 Linux ReleaseFast
libghostty builds.
## TinyIo
I think the main complexity introduction here is our alternate `std.Io`
implementation `TinyIo`. This is an IO implementation that implements IO
operations we need through direct syscalls and does not support
concurrency or any other options like network, progress, etc.
Why? Because of the way `std.Io` works through vtable dispatch, the
linker and dead code removal can't prune ANY of the function pointers.
So our binary has full implementations of all the networking,
concurrency, etc. related code even though we don't use it.
This has a runtime effect too: even though we put `std.Io.Threaded` in
single-threaded mode, it still allocates ~256KB of TLS _per thread_, and
its raw struct state is ~18KB (versus 80 _bytes_ for `TinyIo`).
For future maintenance: I exhaustively implemented the vtable rather
than use the failing vtable from Zig stdlib so any Zig changes to add
new fields to this error so we can determine if we want to support it or
not.
Release libghostty-vt builds carefully avoid referencing
std.Options.debug_io because its default implementation is
std.Io.Threaded, and referencing that vtable keeps every operation
Threaded supports linked into the binary: roughly 110KB of unreachable
code. Nothing references it today, but any std.debug.print,
std.debug.lockStderr, or std.log default-handler call added to
release-reachable code would silently reintroduce all of it.
Declare std_options_debug_io in the root module so std uses our value
instead of constructing the Threaded default. Development builds
(Debug, ReleaseSafe, tests) forward the std default so std.debug.print
and friends work normally. ReleaseFast and ReleaseSmall builds declare
it as a @compileError: since std only analyzes the declaration lazily,
at the moment something references a debug Io code path, the error
fires exactly at the offending reference, turning a silent size
regression into a build failure with a message explaining the
alternatives.
Release binaries are byte-identical when the guard is not tripped.
Add a new Io implementation `TinyIo` that only supports the operations
we need and doesn't support concurrency. This shrinks the binary size
of libghostty by anywhere from ~100KB (macOS) to ~200KB (Linux) and
runtime memory requirements by over 256KB (the thread-local storage
`std.Io.Threaded` creates plus the 18KB threaded structure is gone).
`TinyIo` is POSIX-only: Windows keeps std.Io.Threaded, and on
freestanding targets (wasm) TinyIo degrades to std.Io.failing
behavior just like before.
It is also exported from the Zig module as `ghostty.TinyIo` so
Zig embedders can opt into the same size win when constructing
terminals.
The default Zig panic handler unwinds the stack and symbolicates it,
which drags in ~160KB worth of helper machinery. For an embedded library
this isn't great because the embedder's environment should be providing
this as long as libghostty is compiled with symbols or has a way to
symbolize.
Change ReleaseFast/ReleaseSmall libghostty-vt builds to use a custom
panic handler. Debug/ReleaseSafe keep the full Zig handlers.
This shrinks libghostty-vt on macOS by ~160KB (~9%).
This PR adds a `+new-tab` CLI action, useful for automation on GTK. This
mainly re-uses machinery added for the `+new-window`, but adds in a
unique surface ID for identifying surfaces for IPC purposes (and
eliminates use of raw pointers for callbacks from notifications).
Use the standard `~/Android/Sdk` capitalization for the Linux SDK
fallback.
This lets NDK discovery work when neither `ANDROID_NDK_HOME` nor an
`SDK` environment variable is set.
Fixes hundreds of complaints about the fact that drag handles cannot be
hidden, on GTK at least.
I'm not sure if we ever made an issue for this? If you come across any
discussions asking for this, please link them here :)
It turns out we never unbound the split from its original tree after
moving, which means `is-split` in particular is desynced and leads to
hilarious artifacts like how `unfocused-split-*` options just stop
working properly. I only realized this is a thing after the naïve
drag handle config option didn't work properly. Fun!
Use the standard ~/Android/Sdk capitalization for the Linux SDK fallback.
This lets NDK discovery work when neither ANDROID_NDK_HOME nor an SDK
environment variable is set.
Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
Ghostty's full termio path answers XTGETTCAP from the static terminfo
map, but `terminal/stream_terminal.zig`, which backs libghostty-vt,
parses the same DCS request and then discards it. There is no XTGETTCAP
effect either, so an embedder cannot restore the replies through the C
API.
Programs query these over SSH instead of assuming the remote host has
the client's terminfo entry. This matters more for an embedder than for
the desktop app, which can install its entry on the remote through shell
integration.
Answer the queries in `stream_terminal` the same way termio does: look
up each requested key in the static terminfo map and write the reply to
the pty, skipping the lookups entirely when no `write_pty` effect is
set. The map now stores null-terminated responses so they can be handed
straight to `write_pty` without copying. `terminal/dcs.zig` and the
termio path are unchanged.
`TN` is handled separately. It names the terminfo entry the terminal
runs as, so it has to agree with `TERM` — which is set in
`termio/Exec.zig`, a layer libghostty-vt does not contain. The library
never sees `TERM` and cannot answer on the embedder's behalf, and
answering with Ghostty's own entry from the static map would misreport
every embedder, so `TN` is intercepted before the map lookup. The name
is instead configured through a new option,
`GHOSTTY_TERMINAL_OPT_TERMINFO_NAME`: the string is copied into the
terminal, names longer than 128 bytes are rejected, and while unset the
query goes unanswered.
This is the first dependency from `src/terminal` on `src/terminfo`, so
libghostty-vt now carries Ghostty's terminfo table: +16,023 bytes
(+1.9%) on a `wasm32-freestanding` `ReleaseSmall` build.
---
AI usage: Created with claude code and opus 5. I have reviewed the code
and made modifications where it made sense. I have also tested this end
to end in an application.
Ghostty's full termio path answers XTGETTCAP from the static terminfo
map, but terminal/stream_terminal.zig, which backs libghostty-vt,
parses the same DCS request and then discards it. There is no XTGETTCAP
effect either, so an embedder cannot restore the replies through the
C API.
Programs query these over SSH instead of assuming the remote host has
the client's terminfo entry. This matters more for an embedder than for
the desktop app, which can install its entry on the remote through
shell integration.
Answer the queries in stream_terminal the same way termio does: look
up each requested key in the static terminfo map and write the reply
to the pty, skipping the lookups entirely when no write_pty effect is
set. The map now stores null-terminated responses so they can be
handed straight to write_pty without copying. terminal/dcs.zig and the
termio path are unchanged.
"TN" is handled separately. It names the terminfo entry the terminal
runs as, so it has to agree with TERM -- which is set in
termio/Exec.zig, a layer libghostty-vt does not contain. The library
never sees TERM and cannot answer on the embedder's behalf, and
answering with Ghostty's own entry from the static map would misreport
every embedder, so "TN" is intercepted before the map lookup. The name
is instead configured through a new option,
GHOSTTY_TERMINAL_OPT_TERMINFO_NAME: the string is copied into the
terminal, names longer than 128 bytes are rejected, and while unset
the query goes unanswered.
This is the first dependency from src/terminal on src/terminfo, so
libghostty-vt now carries Ghostty's terminfo table: +16,023 bytes
(+1.9%) on a wasm32-freestanding ReleaseSmall build.
Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
This introduces a new effect for Zig/C callers to detect unknown
sequences.
This PR starts only with APC, but the API shape is such that we can add
other types (OSC next) in future PRs. The goal of this is to have zero
overhead in the disabled/undetected (both) case, and minimal overhead in
the detected case.
This is important in particular for libghostty consumers because it
allows them to implement their own custom protocols and/or support
features libghostty doesn't support. It isn't possible to support them
at the same performance libghostty does but supporting them in general
is usually valuable.
From a Zig API to enable this, users must set `unknown_max_bytes` for
the APC handler AND update their stream handler to recognize unknown
sequences. The built-in `stream_terminal` stream type has an exposed
callback for this, so both must be set.
Fix two resource leaks when the Wayland background blur region changes.
Temporary `wl_region` objects were destroyed only after an error. They
are now always destroyed after the blur request. The previous cached
blur region is also released before its replacement.
This change does not alter protocol selection or add new Wayland
protocols.
Testing:
- Verified blur on KDE Plasma 6.7.4 with GTK 4.22.4.
- Verified with `zig build -Doptimize=ReleaseFast`.
This code was written with assistance from GPT 5.6 Sol and manually
reviewed.
Destroy temporary Wayland regions after each blur update and release
the previous cached blur region before replacing it. This prevents
resources from leaking while the blur region changes.
APC payload bytes are bulk consumed, but the terminating byte still passed
through the generic parser action loop. Handle ESC and C1 ST directly after
bulk consumption while leaving other transitions on the scalar path.