Add a per-row encoded cell width to the PAGE grid format. Rows
previously always spent eight bytes per cell, but a plain text cell
carries only a codepoint: on line-shaped scrollback most encoded
bytes were predictable zeros that still had to pass through CRC32C,
BLAKE3, both codecs, and any transport compression the caller
applies.
Each row now declares one of four widths in previously reserved row
flag bits, chosen canonically as the smallest width admitted by the
bitwise OR of the row cell words: one or two bytes transport a bare
codepoint, four bytes transport the low word half (any content kind,
style IDs up to sixty-three, no wide or flag or hyperlink bits), and
eight bytes remain the full word. Every width is a truncation on
encode and a zero-extension on decode, so narrow rows encode and
decode as vectorizable integer loops, one and two byte rows need at
most surrogate replacement and skip cell normalization entirely, and
full-width rows keep the existing bulk copy. Decoders use the
declared width for framing and accept rows encoded wider than
necessary. Rows containing wide characters, hyperlinks, semantic
content, or large style IDs still use the full width, which leaves
CJK-heavy content unchanged.
Benchmark deltas at this commit (terminal-snapshot, M-series,
ReleaseFast, 1 MB corpora):
ascii lines 1-70: 7.66 MB -> 1.03 MB (7.4x)
encode 5.8 -> 2.0 ms, decode 8.1 -> 2.7 ms
ascii full-wrap: 8.04 MB -> 1.04 MB (7.7x)
encode 5.4 -> 1.3 ms, decode 7.2 -> 1.8 ms
utf8: unchanged (wide cells keep rows at full width)
For a caller compressing the stream, the lines snapshot end to end
with zstd -1: encode plus compress 18.5 -> 2.8 ms, decompress plus
decode 15.8 -> 3.6 ms, and the compressed size itself drops from
1.35 MB to 0.86 MB because the packed stream is denser for the
entropy coder.
PAGE payloads were decoded through a stack of stream adapters:
a CRC32C-hashing reader over a length-limited reader over the
BLAKE3-hashing snapshot reader. Every row paid several adapter
crossings and both hashes were fed row-sized chunks, which kept
BLAKE3 out of its efficient many-block path and made adapter
overhead about a quarter of decode time.
Decode now reads the remaining payload into a scratch buffer with
one bulk read, so each hash sees the payload as a single update, and
then parses the tables and grid from a flat in-memory reader. Row
headers are also read as one three-byte read instead of two calls.
Staging is capped at 8 MiB, far above any standard-capacity page
payload, so a hostile declared length cannot force a large
allocation; larger payloads fall back to the streaming path. CRC
validation and exact-exhaustion checks are unchanged, with the
staged reader checked for leftover bytes to preserve
PayloadNotExhausted semantics.
Benchmark deltas at this commit (terminal-snapshot, 1 MB corpora):
ascii lines 1-70: decode 12.2 -> 8.1 ms (encode unchanged)
ascii full-wrap: decode 11.1 -> 7.2 ms
utf8: decode 3.1 -> 2.1 ms
Relative to the previous wire format and codecs, the series is a
16.0x encode and 14.8x decode improvement on line-shaped scrollback
at 4.5x smaller wire size.
PAGE decoding verified the complete native integrity of every decoded
page unconditionally, building per-cell reference maps that accounted
for roughly a fifth of decode time. The decoder normalizes every
semantic value while decoding, so a completed decode upholds page
invariants by construction and the verification only defends against
decoder bugs. Follow the native page policy instead: assertIntegrity
and friends run full verification only when slow runtime safety is
enabled, which keeps the check in debug and test builds where those
bugs are caught.
Benchmark deltas at this commit (terminal-snapshot, 1 MB corpora):
ascii lines 1-70: decode 15.5 -> 12.2 ms (encode unchanged)
Rework the PAGE grid encoding for codec speed and size. This is a
breaking change to the work-in-progress version 1 wire format.
Cells were previously a fixed 16-byte header plus inline grapheme
suffixes: one byte each for content kind, width, and flags, a
reserved byte, 16-bit style and hyperlink IDs, a 32-bit value, and an
always-present 32-bit suffix count that was almost always zero. Cells
are now one 64-bit little-endian word with a documented bit registry
that carries the hyperlink ID in its high bits. The layout
deliberately coincides with the native cell so clean rows encode as a
straight copy of page memory and decode as one bulk read plus an
in-place normalization pass; a comptime check falls back to a
portable field-by-field codec if the native layout ever diverges.
Each row header also gains an encoded cell count so trailing default
cells are elided instead of spending 16 bytes apiece encoding
nothing: on typical shell output most of every row is blank, and
measurement showed 97% of encoded snapshot bytes were zero. Grapheme
suffixes move out of the cell stream into a per-grid section of
(row, column, codepoints) entries, which keeps row decoding
fixed-stride and bulk-copyable.
Decode ID remapping switches from hash maps to direct-indexed tables
sized by the 16-bit encoded ID space, removing per-styled-cell hash
lookups.
Benchmark deltas at this commit (terminal-snapshot, M-series,
ReleaseFast, 1 MB corpora):
ascii lines 1-70: 34.16 MB -> 7.66 MB (4.5x)
encode 92.8 -> 18.2 ms, decode 119.8 -> 28.0 ms
ascii full-wrap: 16.01 MB -> 8.04 MB (2.0x)
encode 43.6 -> 18.4 ms, decode 56.2 -> 25.6 ms
utf8: 4.33 MB -> 1.90 MB (2.3x)
encode 12.4 -> 4.9 ms, decode 19.0 -> 9.6 ms
Measures the terminal binary snapshot codecs in both directions
against the same terminal state. Setup feeds a pre-generated VT
stream (for example from ghostty-gen ascii) to a terminal outside
the timed region.
Baseline measurements at this commit (M-series, ReleaseFast, 80x24,
unlimited scrollback, 1 MB corpora, per-loop time with setup
subtracted):
ascii lines 1-70: 34.16 MB encode 92.8 ms decode 119.8 ms
ascii full-wrap: 16.01 MB encode 43.6 ms decode 56.2 ms
utf8: 4.33 MB encode 12.4 ms decode 19.0 ms
The ascii generator emits an unbroken stream of printable bytes, which
exercises terminal wrapping but produces only full-width rows. Add
line-min and line-max options that emit CR LF-terminated lines with a
uniformly distributed printable length so generated corpora can also
model shell-like output where most rows end well before the last
column. The default behavior is unchanged.
In the html formatter every page is formatted in a div. When the div
closes it causes a newline in the html rendering. In order to fix this a
newline is now removed whenever the div is closed (if there are any
newlines waiting to be rendered - as far as I could see in my testing
there was always one).
I thought of trying to add a test but could not think of a way to do so
without adding a massive blob of html into the file.
Before (orange was added by me to show where the div ends):
<img width="1278" height="586" alt="image"
src="https://github.com/user-attachments/assets/473ba28d-bec0-481f-9a89-a6a72c9a3657"
/>
After:
<img width="959" height="439" alt="image"
src="https://github.com/user-attachments/assets/27bae0d3-cc82-4dc8-aa72-c4a8b0f7d424"
/>
No AI was used in this pr.
### What
Comment-only cleanup in `src/termio/message.zig`:
- Fixes a subject-verb agreement error in the `Message` union's doc
comment: "the number of messages ... are also very few" -> "is also
very small"
- Capitalizes "pty" to "PTY" in several doc comments
### Why
Caught while reading through `message.zig`. No functional changes,
doc comments only.
Builds on #13544
This adds a new CONTINUATION record type that is sent before READY.
CONTINUATION contains the bytes (if any) that will bring a ground-state
VT state machine up to the same state.
This allows snapshotting a terminal instance that is, for example,
blocked waiting for a caller to complet an in-flight Kitty graphics
protocol send. In practice, I think this will be rare. But in theory, it
avoids a DoS-type attack.
The continuation state must be the MINIMAL set of bytes that will move
the virtual terminal state from a ground to non-ground state. The reason
it must be minimal is because any extra bytes can duplicate work into the
terminal that might already exist.
- "the number of messages we send to the IO thread are also very few"
had a subject-verb agreement issue; reworded to "is also very small"
- Capitalized "pty" -> "PTY"
This adds opt-in continuation tracking to `terminal.Stream` that allows
any caller to call `writeContinuation` in order to get the minimum bytes
necessary from a grounded parser state to the identical state.
This enables reliable stream restart across serialization states, which
could be used for local restart, networked terminals, etc. For me, this
is used for multiplexers. :)
**LLM usage:** I wrote the continuation tracker myself, used a mix of
5.6+Fable to review it for me, applied their feedback directly. Only
place with predominantly AI code are tests, which I reviewed. Commit and
PR message written myself.
## Implementation
The implementation of this was really carefully done to avoid any
negative performance impact particularly when continuation tracking is
_off_.
The way this work is simple:
1. ESC is the only char that leaves the ground state and most ESC
sequences are short. So if we're in a non-ground state, we do a
backwards vectorized search to find the last `ESC` in the input slice.
If one doesn't exist, we assume we found it previously and store the
whole slice (rare, since ESC sequences are usually short like I said).
2. If we're in the ground state that means we only have a potential
incomplete UTF-8 codepoint, so we find the lead UTF-8 byte.
3. When writing, we normalize the suffix to drop things like BEL
commands that would've already been handled to avoid double-calling.
## Performance
No real impact.
Via `ghostty-bench +terminal-stream`.
Corpus | Main | PR w/ Tracking Off | PR w/ Tracking On
-- | -- | -- | --
Plain ASCII (256 MiB) | 175.4 ms | 175.8 ms | 175.5 ms
UTF-8 (32 MiB) | 268.4 ms | 270.0 ms | 269.9 ms
5% invalid UTF-8 (32 MiB) | 316.4 ms | 317.8 ms | 320.2 ms
CSI-heavy (32 MiB) | 145.0 ms | 146.3 ms | 145.9 ms
OSC (32 MiB) | 1621.0 ms | 1627.5 ms | 1638.3 ms
Kitty APC (128 MiB) | 95.5 ms | 96.9 ms | 96.3 ms
Mixed traffic (32 MiB) | 172.4 ms | 172.0 ms | 172.3 ms
Giant APC (128 MiB) | 38.3 ms | 38.3 ms | 40.8 ms
This adds opt-in continuation tracking to `terminal.Stream` that allows
any caller to call `writeContinuation` in order to get the minimum bytes
necessary from a grounded parser state to the identical state.
This enables reliable stream restart across serialization states, which
could be used for local restart, networked terminals, etc. For me, this
is used for multiplexers. :)
## Implementation
The implementation of this was really carefully done to avoid any
negative performance impact particularly when continuation tracking is
_off_.
The way this work is simple:
1. ESC is the only char that leaves the ground state and most
ESC sequences are short. So if we're in a non-ground state, we
do a backwards vectorized search to find the last `ESC` in the
input slice. If one doesn't exist, we assume we found it previously
and store the whole slice (rare, since ESC sequences are usually
short like I said).
2. If we're in the ground state that means we only have a potential
incomplete UTF-8 codepoint, so we find the lead UTF-8 byte.
3. When writing, we normalize the suffix to drop things like BEL
commands that would've already been handled to avoid
double-calling.
## Performance
Via `ghostty-bench +terminal-stream`
Corpus main tracking off tracking on
plain ASCII (256 MiB) 175.4ms 175.8ms 175.5ms
UTF-8 (32 MiB) 268.4ms 270.0ms 269.9ms
5% invalid UTF-8 (32 MiB) 316.4ms 317.8ms 320.2ms
CSI-heavy (32 MiB) 145.0ms 146.3ms 145.9ms
OSC (32 MiB) 1621.0ms 1627.5ms 1638.3ms
Kitty APC (128 MiB) 95.5ms 96.9ms 96.3ms
mixed traffic (32 MiB) 172.4ms 172.0ms 172.3ms
giant APC (128 MiB) 38.3ms 38.3ms 40.8ms
Every page is formatted in a div, and when the div closes it creates a newline in the html rendering.
In order to fix this a newline is now removed whenever the div is closed (if there are any newlines waiting to be rendered).
## Summary
- normalize circular-buffer metadata after every resize
- retain the oldest values when shrinking below the current length
- cover partial shrink, exact-length shrink, and empty-to-zero
boundaries
## Root cause
`resize` rotated live values to index zero before reallocating, but only
repaired `head` and `full` when capacity grew. Shrinking a partially
filled buffer could therefore leave `head` beyond the new allocation and
report a length greater than capacity. A later append could index
outside the resized storage.
## Validation
- `zig fmt --check src/datastruct/circ_buf.zig`
- `zig test src/circ_buf_test.zig --test-filter 'CircBuf resize'` using
a temporary import harness: 8 tests passed
Adds "Copy" and "Export to file" buttons to the Terminal IO inspector so
recorded VT events can be saved outside the app for sharing or analysis.
Found myself needing/wishing for this while I was debugging my tmux fork
with libghostty-vt.
Disclaimer: I haven't considered performance at all, so please lmk if
there are anything here you would like me to optimize.
The masked-compare scan that finds bulk-copyable cell runs still
processed one cell per iteration and remained the largest single
cost in a column reflow.
Scan whole groups of cells at a time using the group variants of the
Mask helper: a group that fully matches the run pattern (and, for
text runs, contains no Kitty virtual placeholder, via eqlAny)
extends the run by the whole group, and any mismatch falls through
to the scalar loop which finds the exact end of the run within it.
The group length comes from the shared simd.lanes helper where the
target has SIMD support and falls back to a plain unrolled group
elsewhere.
1.19x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles).
Combined with the preceding reflow optimizations, resize with reflow
is 5.8x faster than before the series.
Finding the length of a bulk-copyable cell run evaluated the
field-wise bulkCopyable predicate plus a style compare per cell,
which compiles to a chain of extracts and branches and had become
the hottest loop in a column reflow.
Once the first cell passes the full predicate, a cell continues the
run iff it matches the first cell in content tag, style id, wide
property, and hyperlink flag, so the continuation test is now a
masked compare of the raw cell bits via the Mask helper, plus a
masked equality test against the Kitty virtual placeholder codepoint
for text runs (placeholders must set a row flag so they take the
slow path). This is slightly stricter than the predicate (a bg-color
cell no longer extends an unstyled text run), which only splits a
copy into multiple runs and remains correct.
1.21x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles).
Two small extensions to the Mask helper, both motivated by the
reflow bulk run scan in the next commits.
fieldMask now accepts dot-separated field paths so a mask can cover
a nested field of a packed struct or packed union member, e.g.
"content.codepoint.data" covers exactly the codepoint bits of a cell
without its padding. Packed union members all share bit offset zero.
Mask gains eqlAny, the "any" counterpart to eql: it returns whether
any value in a group has masked fields equal to the expected
pattern. This supports run scans that must stop when a sentinel
value appears anywhere in a group, such as the Kitty virtual
placeholder codepoint which requires slow-path handling.
In `resizeCols`, stash the most recently finished source node
instead of destroying it, so we can recycle it without a bunch of
syscalls.
1.30x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles),
with system time dropping from 34ms to 8ms per run.
reflowRow computed the capacity for prospective destination pages on
every source row via Capacity.adjust, which performs a full page
layout calculation to find the available grid space.
The result only depends on the source page, and reflow visits source pages
sequentially and never revisits one, so memoize the adjustment per
source page so we only do this once.
1.06x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles).
Reflow copied every cell through a per-cell state machine
(writeCell) that dispatches on content tag, wide property, grapheme,
hyperlink, and style handling, and advances the destination cursor
one cell at a time. The vast majority of cells in practice are
narrow text or bg-color cells with no managed memory that share a
single style across long runs.
reflowRow now scans ahead for the run of such cells bounded by the
remaining space in the destination row, copies the run with a single
memcpy, and adjusts the style ref count once for the whole run via
useMultiple. Wide characters, spacers, graphemes, hyperlinks, Kitty
placeholders, and rows containing tracked pins all take the original
per-cell path, and a style set failure falls back to writeCell which
handles growing page capacity.
2.19x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles).
Memoize the most recent style mapping and when there is a reuse
bump the ref with `use()`. This avoids a lookup (`addWithId`) on
every single styled cell.
1.25x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles).
Reflow scanned the full tracked pin list for every source cell it
copied, twice per cell in the wide-character case, even though pins
are rare and at most a handful exist. Each check also went through
node.page(), which can restore a compressed page just to compare
pointers.
reflowRow now determines once per row whether any tracked pin is on
the source row and skips the per-cell pin scans entirely when there
is none, which is the overwhelmingly common case. The comparisons
use node identity instead of pages: a node owns exactly one page, so
they are equivalent, and this avoids the restore hazard.
1.09x faster on ghostty-bench +terminal-resize --mode=cols (120x80
terminal, 10k-line scrollback, shrink/grow column reflow cycles).
A state directory with the wrong permissions left the terminfo cache
failing with errors that named no path, so there was nothing to act on:
$ ghostty +ssh-cache --add=user@host
Error: Unable to add 'user@host' to cache. Error: error.AccessDenied
Every +ssh-cache failure now names its cache file, and +ssh no longer
swallows cache-related errors.
Error messages in these actions are also lowercased after the "Error: "
prefix and append the error with ": {t}" rather than a second "Error: ".
Ref:
https://github.com/ghostty-org/ghostty/issues/9393#issuecomment-5145799368
Expose complete encode and decode entry points directly from terminal.snapshot instead of requiring terminal.snapshot.snapshot. Reorder the decode APIs to accept the allocator and I/O context before the reader.
A state directory with the wrong permissions left the terminfo cache
failing with errors that named no path, so there was nothing to act on:
$ ghostty +ssh-cache --add=user@host
Error: Unable to add 'user@host' to cache. Error: error.AccessDenied
Every +ssh-cache failure now names its cache file, and +ssh no longer
swallows cache-related errors.
Error messages in these actions are also lowercased after the "Error: "
prefix and append the error with ": {t}" rather than a second "Error: ".
Ref: https://github.com/ghostty-org/ghostty/issues/9393#issuecomment-5145799368
Publish each screen's logical history extent before READY so clients can size scrollbars while older pages are still arriving. Keep the value advisory and continue deriving native PageList totals from decoded pages.
Reduce HISTORY to its structural screen key and page count, and update the format documentation, Kaitai schema, verifier, and versioned fixtures.
Treat FINISH as the self-delimiting snapshot boundary instead of peeking for end-of-file. Normal decoding now leaves continuation bytes unread, allowing snapshots and live protocol data to share a stream without waiting for closure.
Add decodeExact for bounded files that still require strict exhaustion, and update the Kaitai schema, documentation, and tests for continuation and sequential snapshot decoding.
Stream complete snapshot records to any std.Io.Writer while retaining one reusable payload buffer for length and CRC calculation. Update BLAKE3 incrementally so checkpoints no longer require rehashing an allocating destination.
Wrap decode hashing in StreamReader to enforce exact checkpoint boundaries. Preserve v1 bytes while allowing snapshots to begin at the current writer position and retaining only valid prefixes on failures.
Route HISTORY sequences by their encoded screen key so both keyed sequence groups can arrive in either order. Keep undeclared and duplicate routing strict, separate HISTORY manifest parsing from page restoration, and clear decoder-only generation state before returning the terminal.
Normalize unknown terminal-wide semantic fields during restore while keeping dimensions and screen count structural. Preserve canonical encoding, ignore reserved mode and tab-stop bits, reset invalid color and scrolling state, and clamp finite scrollback policies to the native range.
Treat HISTORY row counts as canonical metadata rather than a reason to reject otherwise usable history. Restore topology from the declared PAGE sequence while keeping record framing, routing keys, and sequence boundaries strict.
Keep the standalone style and hyperlink codecs strict while allowing PAGE and SCREEN decoders to discard invalid optional data at boundaries they own. Normalize invalid styles to defaults, ignore unrepresentable hyperlinks, reuse duplicate entries, and validate hyperlink values before encoding.
Keep SCREEN encoding strict while allowing decoding to recover from unknown or noncanonical semantic state. Cursor positions now clamp to the restored active area, and invalid enum values, reserved bits, and optional state degrade to native defaults.
Keep snapshot grid encoding strict by rejecting malformed wide-cell relationships before they can produce invalid wire data.
Decode untrusted grids liberally while preserving record alignment. Unknown semantic values and content kinds degrade to safe defaults, optional graphemes and hyperlinks are dropped when invalid or over capacity, and malformed wide-cell markers normalize to narrow cells.
Treat Kitty virtual placeholder codepoints as ordinary valid grid content during snapshot restore and derive the native row lookup hint from decoded cells. Image and placement registries remain intentionally omitted.
Cover the behavior with a complete snapshot round trip containing a real virtual placement and its grapheme diacritics.
Describe the complete version 1 snapshot format with a Kaitai schema and make every golden fixture self-describing for automatic discovery. Add a verifier that compiles the schema, parses all fixtures, and checks record checksums, checkpoint digests, and cross-record invariants.
Preserve Kaitai metadata when generating fixture candidates and provide the compiler and Python runtime dependencies through the development shell. Keep the mode registry portable to Kaitai JavaScript targets so the complete fixture also works in the web IDE.