This prevents us from pulling in test images that trigger some
anti-virus scanners. It's also smaller since it only has the necessary
bits that we need.
This also updates to the latest release: 0.4.0-alpha.10.
## Summary
- Prevent desktop notification title and body truncation from producing
invalid UTF-8.
- Truncate fixed-size buffers to the longest valid UTF-8 prefix.
- Add regression tests for multibyte characters at the buffer boundary.
Fixes#13795
## Testing
- Confirmed the original reproducer produces `[Invalid UTF-8]` with the
installed Ghostty.
- Confirmed the patched Ghostty displays a valid, truncated
notification.
- Added tests covering both notification title and body truncation.
## AI Usage Disclosure
I used OpenAI Codex to investigate the root cause, implement the fix and
regression tests, run validation, and help prepare the issue and PR
descriptions. I reviewed and understand the submitted change.
Store the Comparable ObjectIdentifier directly instead of wrapping the
only sort key type in AnySortKey.
The expected deterministic ordering of equal terminal command titles is
also now verified by a unit test.
Previously, formatting a cursor at the right edge emitted CUP, which
cleared pending wrap. Replayed output then overwrote the edge cell
instead of wrapping before the next printable character.
When pending wrap is set, move to and reformat the final cell to restore
the flag through normal VT behavior. Emit cursor pen state afterward and
cover replay plus pin-map behavior with a regression test.
Previously, formatting a cursor at the right edge emitted CUP, which
cleared pending wrap. Replayed output then overwrote the edge cell
instead of wrapping before the next printable character.
When pending wrap is set, move to and reformat the final cell to restore
the flag through normal VT behavior. Emit cursor pen state afterward and
cover replay plus pin-map behavior with a regression test.
Store the Comparable ObjectIdentifier directly instead of wrapping the
only sort key type in AnySortKey.
The expected deterministic ordering of equal terminal command titles is
also now verified by a unit test.
Remove the unused, callback-based detachable header implementation. It
supported the previous terminal inspector, which has since been deleted
(fdbe4343c). The current inspector uses DetachableHeader directly.
Remove the unused, callback-based detachable header implementation. It
supported the previous terminal inspector, which has since been deleted
(fdbe4343c). The current inspector uses DetachableHeader directly.
Continues the Belarusian (`be`) translation for v1.4 per #13766.
Translates the remaining 181 strings (mostly the newly-localized command
palette), bringing `be` to 253/253.
A custom memory pool for Wasm that grows by exactly one item size per
growth and shares the pool across the entire Wasm-module instead of
per-terminal.
Some background on why `std.heap.MemoryPool` is considered harmful for
WebAssembly:
First, the std.heap.MemoryPool grows 1.5x at each growth point. The
backing allocator for that is usually a GPA which is the BrkAllocator
for wasm. This grows by power-of-two big-allocation slots. If you pair
these together you get a massive permanent linear memory growth.
On non-wasm targets, the memory growth doesn't matter because these are
virtual memory mappings that don't cost physical memory, but wasm
doesn't work that way. Also on native targets, the syscalls to allocate
memory are very expensive (relatively), so it makes sense to allocate
large virtual memory chunks and avoid them. Again, wasm doesn't work
this way.
Second, we were using one pool per terminal. On wasm, this meant that we
paid for the free list N times. On non-wasm, this makes sense because
the synchronization overhead has so far been measurable enough under
load to be prohibitive (although, I'm still skeptical about this and
want to look into it). On wasm, we build single-threaded modules, so we
can use a global free list without any extra overhead.
## Benchmarks
80x24 terminal with 1000-line scrollack processing 16MB of plain ASCII.
| Scenario | Before | After |
| ------------------------------- | --------: | --------: |
| Fresh instance | 0.56 MiB | 0.56 MiB |
| First `terminal_new` (delta) | +3.44 MiB | +0.88 MiB |
| One filled terminal (total) | 4.00 MiB | 1.88 MiB |
| Each additional filled terminal | +3.00 MiB | +0.44 MiB |
| 5 filled terminals (total) | 16.00 MiB | 4.06 MiB |
Throughput numbers are unchanged on wasm and native (to be expected in
the latter because this is all gated on
wasm).
Note I'm still very much optimizing the above numbers! This is just my
first big win.
**AI usage:** None used except to validate and judge.
A custom memory pool for Wasm that grows by exactly one item size per
growth and shares the pool across the entire Wasm-module instead of
per-terminal.
Some background on why `std.heap.MemoryPool` is considered harmful for
WebAssembly:
First, the std.heap.MemoryPool grows 1.5x at each growth point. The backing
allocator for that is usually a GPA which is the BrkAllocator for wasm.
This grows by power-of-two big-allocation slots. If you pair these together
you get a massive permanent linear memory growth. On non-wasm targets,
this doesn't matter because these are virtual memory mappings that don't
cost physical memory, but wasm doesn't work that way.
Second, we were using one pool per terminal. On wasm, this meant that
we paid for the free list N times. On non-wasm, this makes sense because
the synchronization overhead has so far been measurable enough under
load to be prohibitive (although, I'm still skeptical about this and want
to look into it). On wasm, we build single-threaded modules, so we can use
a global free list without any extra overhead.
## Benchmarks
80x24 terminal with 1000-line scrollack processing 16MB of plain ASCII.
| Scenario | Before | After |
| ------------------------------- | --------: | --------: |
| Fresh instance | 0.56 MiB | 0.56 MiB |
| First `terminal_new` (delta) | +3.44 MiB | +0.88 MiB |
| One filled terminal (total) | 4.00 MiB | 1.88 MiB |
| Each additional filled terminal | +3.00 MiB | +0.44 MiB |
| 5 filled terminals (total) | 16.00 MiB | 4.06 MiB |
Throughput numbers are unchanged on wasm and native (to be expected in
the latter because this is all gated on wasm).
Zig default's Wasm stacks to 1MB. Change it to 128 KB instead.
This removes 896 KiB from every Wasm instance's initial linear memory
reservation. That means that simply _loading_ `ghostty-vt.wasm` is down
this much.
Through various workload benchmarks of real terminal snapshots,
artificial worst case full ascii, full styled, full emoji, full mixed,
etc. workloads, I wasn't able to get a stack to go above 17 KB, so 128
KB is VERY generous. Lets start here.
Zig default's Wasm stacks to 1MB. Change it to 128 KB instead.
This removes 896 KiB from every Wasm instance's initial linear memory
reservation. That means that simply _loading_ `ghostty-vt.wasm` is down
this much.
Through various workload benchmarks of real terminal snapshots,
artificial worst case full ascii, full styled, full emoji, full mixed,
etc. workloads, I wasn't able to get a stack to go above 17 KB, so 128 KB
is VERY generous. Lets start here.
- Drop redundant 'было' copula with short-form participles (lines 1767, 1769)
- Use short predicative form 'недаступна' (line 1780)
- 'у двух фарматах:' instead of dash-construction (line 156)
- 'Аднавіць' instead of 'Паўтарыць' for Redo (semantic pair with Undo)
- Fix 'у' → 'ў' after 'ANSI' (7 places)
- Align label/description wording for Split Zoom, Read-Only, Float on Top, Secure Input
- 'усе акны' instead of 'усе вокны' (consistent with 'акно')
- 'калі яна ёсць' instead of 'даступная' (if present ≠ available)
- Infinitive 'Дадаць' instead of imperative 'Дадайце'
Key events without a kitty entry fall back to writing their UTF-8 text
directly. On GTK, keys whose unshifted keysym is a dead key or level 5
latch have no unshifted codepoint and take this path. With event type
reporting enabled, releases therefore emitted the same text as presses
and duplicated characters in applications such as Neovim.
Skip the raw text fallback for release events while retaining it for
presses and repeats. Keep the guard in the shared encoder so release
events for identified keys still retain the UTF-8 data used to derive
alternate keys.
Cover releases with and without report-all mode, and verify that repeat
events continue to emit fallback text.
- https://github.com/ghostty-org/ghostty/discussions/12192
- https://github.com/ghostty-org/ghostty/discussions/12084
- https://github.com/ghostty-org/ghostty/discussions/12433
- https://github.com/ghostty-org/ghostty/discussions/13816
## Testing
- `zig build test-lib-vt -Dtarget=x86_64-linux-gnu`
- `zig build -Demit-lib-vt -Dtarget=x86_64-linux-gnu`
- `zig build`
- Verified the regression test fails without the release guard
- Manually tested the GTK backend under Wayland/Sway and X11/XWayland,
with the GTK simple input context and ibus 1.5.34:
- Ergo-L `!` and `'`
- Spanish `[`, `{`, `]`, and `}`
- Presses, repeats, and both modifier-release orders in `nvim --clean`
- Dead-key composition and cancellation
- Unicode hexadecimal input
- Full kitty keyboard mode with `kitty +kitten show_key -m kitty`,
including composed text
## AI disclosure
OpenAI Codex assisted with investigating the reports, reviewing the GTK
and kitty input paths, extending the regression tests, running
validation, and drafting this description. I reviewed the final code,
edited this description, manually performed the tests listed above, and
understand how the change interacts with the input encoder.
When loading a theme from a path that includes a tilde:
```
theme="~/.cache/wal/colors-ghostty"
```
ghostty currently fails with the following error:
```
cannot include path separators unless it is an absolute path
```
This PR tries to expand the ~ of the path. If there is no ~ or expansion
fails, it falls back to the unexpanded value.
Replace a bunch of type-specific Wasm allocation functions with a
generic byte allocator and reusable opaque out-parameters for pointers.
This makes it a lot more ergonomic (relatively) to use the Wasm
interface and removes a dozen or so exports.
This also updates the `ghostty_type_json` `abi` field with a maximum
alignment value that host sides can use to keep every allocation aligned
properly, easily, without hardcoding numbers.
This adds a test to verify this all works as intended and runs in CI.
Replace a bunch of type-specific Wasm allocation functions with a generic
byte allocator and reusable opaque out-parameters for pointers. This
makes it a lot more ergonomic (relatively) to use the Wasm interface
and removes a dozen or so exports.
This also updates the `ghostty_type_json` `abi` field with a maximum
alignment value that host sides can use to keep every allocation aligned
properly, easily, without hardcoding numbers.
This adds a test to verify this all works as intended and runs in CI.
Key events without a kitty entry fall back to writing their UTF-8 text
directly. On GTK, keys whose unshifted keysym is a dead key or level 5
latch have no unshifted codepoint and take this path. With event type
reporting enabled, releases therefore emitted the same text as presses
and duplicated characters in applications such as Neovim.
Skip the raw text fallback for release events while retaining it for
presses and repeats. Keep the guard in the shared encoder so release
events for identified keys still retain the UTF-8 data used to derive
alternate keys.
Cover releases with and without report-all mode, and verify that repeat
events continue to emit fallback text.
GhosttyCell was exposed as a raw integer while its manifest entry was only an alias, forcing bulk-read consumers to duplicate the internal cell bit layout.\n\nAdd reflection helpers for packed structs and tagged unions, and keep the C-facing layout metadata next to Cell itself. Extend the ABI manifest and schema with recursive bit descriptors so every content arm, including palette and RGB backgrounds, can be decoded without hardcoded masks.\n\nDocument manifest-driven cell decoding and test the metadata against Zig reflection and real cell values.
The ABI manifest previously had no machine-readable grammar or test that
the public export conformed to it.
Define a Draft 2020-12 schema and add a build check that executes
ghostty_type_json for native and wasm libraries before validation. Run
both forms in CI and publish the schema with the generated API docs.
The type metadata export only described extern struct layouts, leaving embedders to mirror enum values and tagged union relationships.
Describe every public C type in a versioned manifest with target and build metadata. Keep union field renames alongside their source tagged unions so the manifest uses public C names without changing Zig value layouts.
Add render state C APIs for iterating only rows that require a redraw
and for marking a completed frame clean in one call.
A one-row update in a 24-row viewport reduces dirty-row discovery from
50 calls to two, while cleanup becomes one call instead of O(N) of rows.
This lower call count is massive for environments where FFI is expensive
(Go, wasm).
The dirty next API outputs the viewport y because it jumps, unlike the
normal sequential next where its trivial for a caller to keep track.
A normal renderer would have to call `ghostty_render_state_get` _eight
times_ to reconstruct the cursor. In languages where FFI is expensive
(Go, wasm, etc.), this showed up in profiles of every frame.
Add a sized cursor snapshot and expose it. Also expose the existing
color snapshot through ghostty_render_state_get and remove the older
dedicated color getter.
Found during my normal Go/wasm adventures.
Add render state C APIs for iterating only rows that require a redraw
and for marking a completed frame clean in one call.
A one-row update in a 24-row viewport reduces dirty-row discovery from
50 calls to two, while cleanup becomes one call instead of O(N) of rows.
This lower call count is massive for environments where FFI is expensive
(Go, wasm).
The dirty next API outputs the viewport y because it jumps, unlike the
normal sequential next where its trivial for a caller to keep track.
A normal renderer would have to call `ghostty_render_state_get`
_eight times_ to reconstruct the cursor. In languages where FFI is
expensive (Go, wasm, etc.), this showed up in profiles of every frame.
Add a sized cursor snapshot and expose it. Also expose the existing color
snapshot through ghostty_render_state_get and remove the older
dedicated color getter.
Partially addresses #13796. Extends #13222.
Previously, `insertText` commits without marked text were delivered via
`sendText`, which applies paste semantics and wraps the text in
bracketed
paste when the program enables it. macOS dictation and other input
methods often commit without marked text, so programs treated dictated
text as a paste: opencode collapsed it into a `"[Pasted ~N lines]"` chip
and Neovim applied paste-mode handling.
`insertText` is only invoked by input methods (IME, dictation, emoji
picker, character viewer); real paste operations use a separate path.
Every non-empty commit is now sent as a key event — the same path
already used for preedit commits since #13222 — so input method text
always arrives as typed input.
Typing is unaffected (the accumulator path returns earlier) and Cmd+V
pastes are unaffected. `committedPreeditTextAction` is renamed to
`committedTextAction` since it no longer only handles preedit commits.
Testing:
- 311 macOS unit tests pass.
- Manually verified on macOS 26: dictation into Opencode and Neovim
arrives inline with no paste handling; emoji picker inserts inline;
Chinese IME composition unchanged; dictation in Neovim normal mode now
behaves as keystrokes, matching Terminal.app.
Notes:
- Dictated "new line" now matches Terminal.app behavior (no newline
with typed-text semantics). The previous behavior came from the paste
path preserving the newline; a follow-up could deliver it as an
Enter keypress if desired.
AI usage: drafted with OMO + OpenCode + DeepSeek V4 Pro assistance;
reviewed, edited, and manually tested by the author.
On a local cache miss, always send our embedded terminfo source to the
remote `tic` instead of accepting any existing entry reported by
`infocmp`.
We also version cache entries using a content-derived hash of our
embedded terminfo. Non-matching entries produce a cache miss and trigger
(re)installation.
Limit individual allocations made while decoding registered glyf
outlines to 64 KB.
Carefully crafted glyf outlines could expand into ~768KB of memory per
glossary entry, which adds up to hundreds of MB per terminal surface.
Across many terminals this could cause issues.
The 64KB number was chosen by inspecting every glyph across Apple
symbols and Noto emoji and the largest single glyph found was 40KB. So,
64KB is generous while limiting each terminal to ~68MB of RAM for max
glyph glossaries.
AI was used only to write initial tests, I rewrote em.
Snapshot encode is now 4-7x faster, decode is 3x faster for wasm builds.
Snapshot decode is particularly important for wasm builds because
libghostty is mainly used on web as a terminal _viewer_ and snapshots
are the best, most efficient way to ship down full terminal state.
The biggest change here is a totally custom software CRC32
implementation, which accounted for ~70% of total decode time. Native
builds on aarch64/x86_64 use dedicated hardware instructions that wasm
doesn't have. We've written a custom CRC32 impl (verified against Zig
stdlib through randomized unit tests) that goes from 0.3 GB/s to 5 GB/s
throughput in V8.
## Benchmarks
Wasm on V8:
| Workload | Encode Before | Encode After | Speedup | Decode Before |
Decode After | Speedup |
|---|---|---|---|---|---|---|
| ascii | 290 MB/s | 1182 MB/s | 4.1x | 318 MB/s | 946 MB/s | 3.0x |
| styled (sgr16) | 387 MB/s | 2771 MB/s | 7.2x | 284 MB/s | 758 MB/s |
2.7x |
| sgr-truecolor | 361 MB/s | 2382 MB/s | 6.6x | 252 MB/s | 766 MB/s |
3.0x |
| cjk | 411 MB/s | 2686 MB/s | 6.5x | 339 MB/s | 1100 MB/s | 3.2x |
| grapheme | 280 MB/s | 1117 MB/s | 4.0x | 287 MB/s | 839 MB/s | 2.9x |
Native on aarch64:
| Corpus | Mode | Before | After |
|---|---|---|---|
| ascii | encode | 40.6 ms | 24.8 ms |
| ascii | decode | 51.2 ms | 49.2 ms |
| utf8 | encode | 45.2 ms | 42.4 ms |
| utf8 | decode | 59.8 ms | 59.5 ms |
**AI usage:** Fable did everything here except write this PR and the
comments. It also wrote the commit messages in this case. I reviewed
everything.
Limit individual allocations made while decoding registered glyf
outlines to 64 KB.
Carefully crafted glyf outlines could expand into ~768KB of memory per
glossary entry, which adds up to hundreds of MB per terminal surface.
Across many terminals this could cause issues.
The 64KB number was chosen by inspecting every glyph across Apple
symbols and Noto emoji and the largest single glyph found was 40KB. So,
64KB is generous while limiting each terminal to ~68MB of RAM for max
glyph glossaries.