Semantic line selection moved to the previous row when the next row
started with different content, then assigned the previous pin an x
coordinate from the next page. During incomplete reflow, a wider next
page produced an out-of-bounds pin and a runtime safety panic.
Set the end column from the page that owns the destination pin. Line
selection now remains valid while crossing mixed-width page boundaries.
Tabstops.reset subtracted one from the column count before iterating
default stops. Although init and resize accept zero columns, resetting
that state with a nonzero interval underflowed and panicked.
Return after clearing when the grid has fewer than two columns. Empty
and single-column tabstop sets now preserve the normal no-stop result.
SelectionGesture passed caller-supplied repeat timestamps directly to
Instant.since. A C API client or non-monotonic timer could provide an
earlier timestamp after a later one, causing a runtime safety panic
while converting negative elapsed seconds to u64.
Compare instants before calculating elapsed time and treat backwards
timestamps as failed repeats. The next press becomes a new single-click
anchor, matching other invalid repeat inputs.
Screen.clearCells accepted a slice but its runtime safety validation
indexed the first and last elements unconditionally. Passing an empty
range therefore panicked before the otherwise valid no-op clear.
Return immediately for an empty slice so validation and managed-memory
bookkeeping only run when there are cells to clear.
Pin movement assumed every page had the same column count. During an
incomplete reflow, crossing into a narrower page could produce an
out-of-bounds x coordinate, while wrapped movement could land on the
wrong row or stop early.
Use destination page widths while moving vertically or wrapping, and
reject points that exceed the resolved page. Add synthetic mixed-width
coverage for movement, wrapping, overflow, and point conversion.
pointFromPin accumulated scrollback rows directly into the u32 Y
field. An unbounded PageList with more than 2^32 rows could overflow
while converting a valid pin and panic in runtime safety builds.
Use checked additions for every cross-page row contribution. If the
pin cannot fit in point.Coordinate, return null through the existing
out-of-range result instead of trapping.
Cell.screenPoint accumulated page row counts in CellCountInt even
though screen point Y coordinates are u32. Once scrollback crossed
65,535 rows, walking back through page metadata overflowed and trapped
in runtime safety builds.
Accumulate directly in u32 so page-local u16 row counts widen before
addition and the result uses the full range promised by point.Coordinate.
Prompt scrolling negated negative deltas to count the requested jumps.
minInt(isize) has no positive signed representation, so a caller could
trigger a runtime safety panic before the search for an earlier prompt
started.
Use @abs to produce the full unsigned magnitude. An extreme negative
request now follows the normal prompt traversal and clamps at the oldest
available prompt.
PageList.scroll negated negative row deltas to obtain their
magnitude. minInt(isize) has no positive signed representation, so
callers could trigger a runtime safety panic before the existing
traversal had a chance to clamp at the top.
Use @abs to calculate an unsigned magnitude that represents every isize
value. The same value now drives both cached-pin and general traversal
paths.
Runtime safety violating scenarios found by GPT 5.6. Verified each one
manually. See each commit.
I'm going to keep searching so not going to merge this yet.
setCursorPos added origin-mode margins to requested row and column
values before clamping them to the scrolling region. A request near
maxInt(usize) overflowed during that addition and crashed instead of
landing on the region boundary.
Use saturating addition for the origin offsets. The existing clamp then
places oversized requests on the bottom-right margin without changing
normal cursor positioning.
Pin.leftWrap and rightWrap calculated the destination using the
remainder after consuming the current row. When that remainder was an
exact multiple of the column count, rightWrap subtracted one from zero
and leftWrap produced a column equal to the width. Dereferencing either
pin could panic. A maximum usize offset on a one-column page also
overflowed the row count.
Base the row and column calculations on the remainder minus one. This
maps exact multiples to the final cell of the correct row and keeps the
maximum offset calculation in range so traversal reports overflow
normally.
Low-level search state accepted an empty needle even though the search
thread normally filters it out. SlidingWindow treated the empty string
as a zero-length match and underflowed while calculating its inclusive
end offset. Active and viewport overlap calculations could also
underflow while loading adjacent pages.
Treat an empty needle as an inactive search with no matches or history,
and saturate the viewport overlap length.
Screen searches only reset cached dimensions while feeding more history.
Selecting or reloading a result immediately after a resize left
flattened highlights pointing at page nodes freed by reflow. The next
selection operation could dereference those stale pointers and crash.
Centralize dimension invalidation and run it before feed, reload, and
selection paths inspect cached state. Add regression coverage for
selecting a cached active match after a column resize.
Screen searches only reset cached dimensions while feeding more
history. Selecting or reloading a result immediately after a resize
left flattened highlights pointing at page nodes freed by reflow. The
next selection operation could dereference those stale pointers and
crash.
Centralize dimension invalidation and run it before feed, reload, and
selection paths inspect cached state. Add regression coverage for
selecting a cached active match after a column resize.
APC payloads such as Kitty graphics images can be megabytes of base64
data, but every byte was dispatched individually: through the VT state
machine table, an apc_put action, the stream handler, the APC protocol
handler, and finally a per-byte ArrayList append in the Kitty command
parser. Five layers of dispatch per byte made large image transfers
far slower than they needed to be.
Add a bulk fast path alongside the existing CSI fast paths in
consumeUntilGround: scan the longest run of apc_put bytes (stopping
at any byte the parse table doesn't treat as APC payload: CAN, SUB,
ESC, and most C1 bytes exit or abort the string state, and 0xA0-0xFF
are ignored by it) and dispatch the run as a single new apc_put_slice
action. The APC handler identifies the protocol from the first few
bytes as before, then passes the remainder of each slice to the
protocol parser in bulk; the Kitty parser appends payload data with a
single appendSlice. Ignored/unknown APC sequences now drop each slice
in O(1) instead of per-byte dispatch.
The fast path is guarded the same way as the CSI fast paths: handlers
with a vtRaw hook (the inspector) keep receiving per-byte apc_put
actions, and the scalar next() path is unchanged.
Also add benchmark support: a `ghostty-gen +kitty` synthetic generator
emitting well-formed Kitty graphics transmit commands with 4 KiB
random base64 payloads (not valid image data; the corpus exercises
the parsing paths, not image decoding), and a `ghostty-bench
+apc-parser` benchmark that measures the stream -> APC -> Kitty parse
path without image decode/storage.
Benchmarks on a 64 MiB corpus (hyperfine, ReleaseFast, x86_64 Linux,
baseline is identical source with only the fast path disabled):
apc-parser: 1.061 s -> 43 ms (~25x)
terminal-stream (kitty): 1.163 s -> 72 ms (~16x)
terminal-stream (ascii): no change
The ascii case was verified with retired instruction counts (perf
stat, pinned to one core) since wall time on the test machine has
4-7 ms of noise: 988,030,458 vs 988,045,833 instructions (+0.0016%),
a fixed startup-size delta; the ground-state hot loop never reaches
the new branch.
The block decoder previously copied literals through variable-length
memcpy calls and expanded every match with word loops that carried an
overcopy fallback in each branch. Real page blocks decode as millions
of tiny sequences, so per-sequence overhead dominated restore time.
Decode short literal runs and in-token matches with blind fixed-width
copies whose margin checks subsume the exact bounds checks they
replace. Expand small repeating periods into pattern-word stores,
copy distant long matches with one exact memcpy, and propagate the
rare non-power-of-two short offsets bytewise. Page corpora restore
13% to 19% faster and text around twice as fast, while compressor
output stays byte-for-byte unchanged.
Replace the fuzz test with a differential property suite which
round-trips generated inputs, validates blocks with an independent
format walker, rejects wrong-size outputs, and decodes corrupted and
truncated blocks. A light version runs as a normal unit test; the
exhaustive version runs when GHOSTTY_LZ4_SLOW is set. An AGENTS.md
records the benchmarking, testing, and verification workflow for
this directory.
Scrollback compression scheduling was only available to Zig callers that
used Terminal directly, leaving C embedders unable to drive the same idle
compression policy.
Define ABI-aware mode and result enums on Terminal and export activity
and compression operations through the C API. Keep scheduling
caller-owned, validate C inputs, and document the incremental contract
with a complete example.
Report unsupported reclamation consistently for full passes so callers
can disable compression on targets that cannot retain decommitted
mappings.
Compression previously stopped whenever the viewport left the active
area, leaving all scrollback resident while a user viewed history.
Traverse complete historical pages through a metadata-only iterator
which skips the contiguous visible range. Restart incremental traversal
after every viewport movement so pages become eligible once they leave
view, while visible pages remain resident for immediate redraw.
Add a PageList-only drain mode for tests and benchmarks, and update
scrollback documentation to describe the offscreen eligibility rule.
Compression scheduling previously postponed its idle timer after every
renderer wake. The inspector redraw loop wakes the renderer
continuously, so opening it could prevent pending scrollback compression
from ever starting.
Track compression-relevant PageList activity with a wrapping 48-bit
token and restart the timer only when the composite Terminal token
changes. This removes the separate dirty bit while reserving 16 bits for
future Terminal-owned compression triggers.
Expose target availability through the terminal package and leave
renderer compression state undefined on unsupported targets so its timer
is never initialized.
The raw LZ4 codec previously kept one match candidate and extended and
copied matches byte by byte. This limited compression quality and made
page restoration substantially more expensive than the reference
decoder.
Pack two candidates into the existing 16 KiB table, accelerate long
literal searches, and compare matching runs a word at a time. Decode
short literals and common repeated patterns with fixed-width copies
while retaining exact bounds checks for malformed blocks.
This keeps the public API, workspace size, block format, and
allocation-free dependency model unchanged.
PageList previously kept only traversal position, so callers had no
central signal for deciding when an incremental compression pass should
run. Scheduling policy had to infer work from output and UI activity.
Track compression dirtiness alongside the PageList continuation state.
Growth preserves valid progress while marking work pending, and resize
or viewport transitions restart from the oldest page. A no-work
verification pass clears the state.
Expose Terminal helpers which report whether compression is required and
run compression against primary scrollback even while the alternate
screen is active. Unsupported retained-memory targets report no work.
Search previously used the normal page access boundary while formatting
history and checking soft-wrap boundaries. Inspecting compressed history
therefore restored its retained mapping and undid the memory
reclamation.
Format through preserved page snapshots and copy row counts and wrap
state into the sliding window's owned metadata. Overlap decisions reuse
the same snapshot, so compressed pages are decoded at most once per
append and remain compressed after matching.
Add a cross-page regression which searches compressed history and
verifies both source nodes retain their compressed representation.
Incremental compression previously exposed its traversal state to
callers, requiring them to coordinate the cursor with PageList lifetime
and topology changes. Read-only consumers also had to restore compressed
nodes to inspect their contents.
Move the continuation state into PageList and expose a single mode-based
compression entry point. Incremental passes restart safely across
mutations and verify a no-work pass before becoming idle, while full
passes leave incremental state fresh.
Add preserved page reads which decode compressed nodes into caller-owned
storage without changing their representation, and migrate the
scrollback compression benchmark to the new API.
Cold-history compression previously required scanning every eligible
page in one call, which makes it unsuitable for an idle-time scheduler.
The inspector also restored compressed pages while traversing collapsed
entries, hiding the representation and undoing reclamation.
Add caller-owned serial state that resumes compression without retaining
node pointers. Each invocation inspects at most eight candidates and
attempts one resident page. List mutations restart safely, while
unsupported or historical viewports stop work. Keep a stateless
whole-history operation for measurement.
Expose metadata-only storage and memory accounting for diagnostics,
update the inspector to restore only expanded pages, and add an
incremental live scrollback benchmark. This remains disconnected from
production scheduling.
PageList could compress individual nodes but had no policy-level
operation for selecting pages that are safe to reclaim. The compressed
state therefore remained reachable only from tests.
Add a stateless pass that considers only complete history pages while
the viewport follows the active area. It gates work on supported
retained-mapping reclamation, reports attempts and retained bytes, and
leaves restoration lazy when a resize pulls compressed history back into
the active area.
Add a live scrollback-compression benchmark for measuring complete
PageList compression and restoration against saved VT corpora. The pass
still has no production callers, and ReleaseFast terminal-stream
comparisons remain within the existing throughput guardrail.
PageList nodes previously exposed Page directly, so introducing a
compressed representation would require every consumer to understand its
state and ownership.
Add resident and compressed node states behind a page access boundary.
Content access transparently recommits and restores retained mappings,
while metadata traversal stays compressed and lifecycle paths can
discard encoded contents without decoding. Compression borrows pool
memory for standard scratch and uses temporary aligned storage for
oversized pages.
Migrate terminal, rendering, search, formatting, and C API consumers to
the new boundary. Hot grow, scroll, and print paths reuse resolved
pages, with an explicit resident-only accessor where live cursor
pointers prove that the page cannot be compressed.
The compression entry point remains private with no production callers,
so normal terminal behavior and scrollback accounting are unchanged.
ReleaseFast terminal-stream comparisons across bulk output, scrolling,
redraw, and erase workloads remain within 2% of the parent revision.
PageList's virtual-memory helpers were tied to pool items even though
the underlying decommit and recommit operations also apply to retained
page mappings.
Move the helpers into terminal/mem.zig and express their different
failure contracts as generic modes. Zero mode preserves the existing
pool invariant and fallbacks, while strict mode only succeeds when the
operating system accepts reclamation and avoids touching memory that
will be restored.
PageList now uses the shared zero mode for its page pool. The strict
path is tested in isolation and remains unused, so this does not enable
page compression yet.
The standalone LZ4 codec had no representation for terminal page
ownership or metadata, so PageList integration would otherwise need to
reconstruct every Page field independently.
Add compress.Page, which embeds the complete terminal Page while
retaining its original virtual mapping and owns only an exact-sized
encoded block. Compression is kept only when the encoded state is
strictly smaller, and scratch output is capped at that profitability
boundary so a future PageList caller can borrow a standard pool item.
Extend the page-compression benchmark with a store mode that measures
the encoded copy, allocation, bounded retention, and eviction path.
Nothing uses compression from PageList yet; this remains isolated
groundwork.
Scrollback compression needs a codec that can be used from libghostty-vt
without pulling in libc, and we need to measure it before integrating it
with terminal page ownership.
This adds an allocation-free raw LZ4 block codec in scalar Zig. Callers
provide the input, output, and fixed-size scratch table. The decoder
uses an exact-size output contract so page metadata mismatches fail
cleanly. Compatibility vectors, boundary cases, random round trips, and
fuzz coverage exercise the block format.
Also adds a page-compression benchmark that operates on reusable raw
page corpora. Compression and decompression have separate modes with
setup outside the timed region, plus a ratio report and no-op baseline.
Nothing uses compression in the terminal yet; this is the isolated codec
and measurement groundwork.
The PageList page pool never returns memory to the OS: destroyed pages
are zeroed and free-listed until the surface exits. Any operation that
shrinks the page count (clearing scrollback, pruning churn, resets,
reflow) therefore retains its high-water RSS forever.
Clearing a full scrollback keeps all of it resident, which at the default 10MB
scrollback-limit is 10MB per terminal of memory that can never be
used again for anything else.
Lots of memes on the internet about this, and it turns out operating
systems give us an answer for this (both Linux and macOS at least),
so let's do it kids.
Pool items can't be individually freed since they live inside arena
chunks, but they are page-aligned and page-multiple sized, so we can
decommit them while they sit in the free list. Our page-aligned
allocation pays off, again!
On Linux, madvise(MADV_DONTNEED) reclaims the pages immediately and
guarantees zero-fill on the next touch, which also lets us skip the
zeroing memset entirely, making destroy cheaper.
On macOS, we zero in place and mark the item MADV_FREE_REUSABLE, which
removes it from the process footprint immediately. Reuse is paired
with MADV_FREE_REUSE when the pool hands a buffer back out so that
footprint accounting stays correct. The zero invariant required by
page reuse holds either way: reusable page contents are either
preserved (our zeroes) or reclaimed and zero-filled by the kernel.
Other platforms and test builds keep the existing memset behavior.
## LLM Notes
Fable 5 found the retention behavior while re-analyzing scrollback
memory, wrote the change and tests, and verified the madvise semantics
empirically with memory probes on macOS and a real Linux kernel, plus
before/after throughput benchmarks on both. I reviewed the analysis,
the diff, rewrote the code to be more idiomatic Zig, and wrote this
commit message you're reading.
PageList skips zeroing pooled page buffers in release builds, relying
on the OS page allocator handing out zeroed pages and destroyNodeExt
zeroing buffers before returning them to the pool. There is a hidden
exception: std.heap.MemoryPool writes its free list node into the
first pointer-size bytes of a free-listed buffer, so a reused buffer
is not fully zero. This is only safe because the page rows array is
laid out at offset 0, a page always has at least one row, and initBuf
fully rewrites every row, overwriting the stale free list pointer.
None of that was written down or checked anywhere, so a future layout
reorder (or a zero-row page) would corrupt pages in release builds
only, in a way that depends on pool reuse patterns. This adds a
comptime assert that a Row covers at least a pointer, a runtime assert
that pages always have at least one row, and comments tying the
invariant together at the layout, initBuf, and pool reuse sites.
Also fixes stale doc comments: deinit referenced a clonePool function
that no longer exists, and Screen tests referenced increaseCapacity by
its old adjustCapacity name.
Layout.init(0) is an explicitly supported special case that produces a
valid zero-capacity set with a zero-size table. But lookupContext had
no guard for it: probing computes `table[hash & 0]` and reads whatever
memory follows the set in its backing buffer, treating those bytes as
an item ID which is then used to index the (also zero-size) items
array, an out-of-bounds read reaching arbitrarily far past the set.
This has never fired in practice because no production page carries a
zero-capacity set today, and where one could occur the adjacent bytes
happen to be zero (which reads as an empty bucket and returns null).
Page.exactRowCapacity legitimately produces zero capacities for pages
without styled or hyperlinked cells though, so any page compaction
work makes this reachable with nonzero adjacent memory: in a Page
layout the styles set can be followed by the grapheme bitmap, which
is initialized to all ones.
Lookups on a zero-capacity set now return null without touching the
table. This also covers add, which looks up before inserting and
already handles the zero capacity correctly after that point by
returning OutOfMemory. All other entry points assert on valid IDs,
which a zero-capacity set cannot have.
PageList.increaseCapacity grows a capacity dimension by doubling it.
If the dimension is zero, doubling "succeeds" without growing: the
page is reallocated and recloned with an identical capacity, violating
the documented guarantee that we always increase by at least one unit.
Every unbounded retry site (startHyperlink, cursorSetHyperlink, the
reflow probes, insertLines/deleteLines) then loops forever reallocating
a page per iteration, and the single-retry sites (styles, graphemes)
fail their retry and silently drop data.
No production page has a zero dimension today, which is why this has
never fired: standard capacities are nonzero and doubling keeps them
nonzero. But exactRowCapacity legitimately returns zero for dimensions
with no content (a compacted plain text page has zero styles, grapheme,
string, and hyperlink capacity), so any compaction work makes this
reachable.
Growth from zero now jumps straight to the standard default for the
dimension rather than doubling. The default is what every standard
page starts with, so single-retry callers are guaranteed enough room
for their pending allocation, whereas doubling from a minimum unit
could still come up short (a single grapheme can need multiple chunks,
and a style set below capacity 3 cannot store anything).
PageList decided whether a page's backing memory belongs to the memory
pool or the heap by comparing its length against std_size. This was super
error-prone and the source of many bugs historically. We locked it down
but its bothered me and has gotten in the way of another feature I've
wanted to do: memory compaction.
First, this commit records the ownership explicitly on each node and uses it
everywhere ownership was previously inferred from size.
Second, createPage gains an exact_size option that forces an exact-size
heap allocation even when the layout would fit a pool item.
Third, compact() now uses it to shrink any page to its minimum size,
including standard pages. Nothing calls compact [YET!] but this is going
to be the key to compressing scrollback history.
This is groundwork for a ton of memory savings. Coming soon.
BitmapAllocator.layout takes a capacity in bytes, but sized its chunk
region as `aligned_cap * chunk_size`, reserving chunk_size times more
memory than the bitmaps can ever address. As a result, the grapheme
region of every standard page reserved 128 KiB with only 8 KiB
reachable, and the string region 64 KiB with only 2 KiB reachable.
About ~180 KiB of waste in every 576 KiB page.
Results for a standard page:
| region | before | after |
|---|---|---|
| grapheme allocator | 131,136 B | 8,256 B |
| string allocator | 65,544 B | 2,056 B |
| page total | 589,824 B (576 KiB) | 409,600 B (400 KiB) |
30% less memory for every standard page in every terminal,
including the preheated pages in the PageList pool.
Hooks up responses to OSC and Kitty color queries if `write_pty` is set
for libghostty.
Also found a memory leak: the OSC parser now releases color operation
request lists during reset.
libghostty-vt already tracked OSC color state but ignored color queries in the standalone stream handler. This meant embedders that installed write_pty still received no response for OSC 4/10/11/12 or Kitty OSC 21 queries.
Resolve the current terminal colors through shared Terminal helpers and encode replies through the write_pty effect. Xterm queries use the fixed 16-bit rgb form, preserve the request terminator, and fall back from cursor to foreground when no cursor color is set. Kitty color queries now report supported terminal-backed keys and return empty values for unset dynamic colors.
Add RGB wire encoders and tests covering the stream handler and C API. The OSC parser now releases color operation request lists during reset, fixing an allocation leak exposed by multi-query OSC color tests.
This optimizes scrolling inside a scroll region (DECSTBM).
## The changes
1. **Stop creating scrollback for top-anchored regions on screens that don't
retain scrollback.** `index()` routed any full-width region with `top == 0`
through `cursorScrollAbove()`, which pushes the scrolled-out row into
scrollback. Every scroll paid `PageList.grow()` plus amortized page pruning,
which includes a 512 KB `memset` each time a page is recycled. These now
use the in-place region scroll instead. CSI S gets the same routing fix.
**Result: 1.05x-1.49x on the bottom-anchored region workloads, 1.25x on
alt-screen full-screen scrolling.**
2. **Add a specialized `Screen.cursorScrollRegionUp()` for the region scroll
hot path.** The previous fast path (`PageList.eraseRowBounded`) paid
per-scroll bookkeeping that exceeded the actual row work.
The new function is built around the invariant that the cursor sits on the
bottom row of a full-width region.
**Result: 1.23x-1.24x on the top-anchored region workloads.**
## Benchmarks
| workload | region (80 rows) | before | after | change |
|---|---|---|---|---|
| scrolling (control) | primary screen, no region | 237 ms | 235 ms | 1.0x |
| scrolling_bottom_region | alt, rows 1-79 | 243 ms | 231 ms | 1.05x |
| scrolling_bottom_small_region | alt, rows 1-40 | 311 ms | 208 ms | 1.49x |
| scrolling_top_region | alt, rows 2-80 | 283 ms | 229 ms | 1.23x |
| scrolling_top_small_region | alt, rows 40-80 | 258 ms | 208 ms | 1.24x |
| alt screen full-screen scrolling | alt, no region | 288 ms | 230 ms | 1.25x |
## LLM Notes
Assisted by Fable 5: it diagnosed the vtebench gap, wrote the benchmark
harness payloads, profiled, and proposed hot paths. I manually wrote the
hot path replacements and had it judge my work.
This optimizes `RenderState.update`, the per-frame call that snapshots
terminal state for the renderer and is the main reason the renderer
thread holds the terminal lock.
Lock hold time is reduced ~2.7x to ~11x depending on the frame.
## The changes
1. iterate page chunks instead of rows in `update`
2. classify cells with masked vector compares.
3. split the update into `beginUpdate`/`endUpdate` phases. There's a
lot to be gained by accumulating data with the lock held and then
processing it out of the lock.
4. generalize the masked-compare scans into `page.Mask`. This is just
a really common pattern we're doing now and it yields a ton of great
value. Its error prone so lets make it a tested helper.
## Benchmarks
Measured with the new `ghostty-bench +screen-clone` modes (`render`,
`render-locked`, `render-clean`, `render-partial`), 120x80 terminal, M4
Max, macOS 26, ReleaseFast, hyperfine means of 10+ runs, per-update
times derived from fixed-count update loops with process startup
subtracted. "Lock held" is the time the terminal lock must be held per
update; "before" held the lock for the entire update.
| scenario | before (lock held) | after (lock held) | after (total) | lock change |
|----------|--------------------|-------------------|---------------|-------------|
| clean frame (nothing dirty) | 202 ns | 19 ns | 19 ns | 10.9x |
| partial frame (1 dirty row) | 290 ns | 54 ns | 54 ns | 5.4x |
| full rebuild, lightly styled | 6.9 µs | 2.5 µs | 3.0 µs | 2.7x |
| full rebuild, fully styled | 9.3 µs | 2.4 µs | 8.0 µs | 3.8x |
| full rebuild, fully styled, 250x150 | 49.9 µs | 9.4 µs | 31.6 µs | 5.3x |
| full rebuild, plain text | 1.9 µs | 1.9 µs | 1.9 µs | 1.0x (memcpy floor) |
The clean and partial cases are the steady-state frame costs (cursor
blink, mouse movement, typing). The full-rebuild cases are the contended
ones: colored scrolling output (build logs, htop, vim) moves the
viewport pin every frame, forcing a full rebuild exactly when the IO
thread is busiest, so that row of the table is where lock contention
actually hurts. Plain text was already at the memcpy floor and is
unchanged.
## LLM Notes
This work was driven by Fable 5: benchmarks, optimizations, the property
test, and the measurements above. I reviewed every line, simplified the
design in a few places (API naming, the Mask helper shape), and re-ran
the verifications myself.
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~5% of total time under writev, all of it log
output: the recording triggers ~120k warnings, dominated by a few
repeated messages ("unimplemented mode: 34", "invalid device
attributes command", "invalid C0 character") that some program in
the recorded session re-emitted on every frame or every prompt.
Each occurrence pays formatting plus a blocking write syscall,
and repeats add no diagnostic value beyond the first: the message
already includes the offending value.
These messages are emitted in response to input that the terminal
application controls, so a misbehaving or merely chatty program
can flood the log indefinitely. This adds a logUnsupportedOnce
helper that suppresses repeats per (call site, value): each site
tracks the distinct keys it has logged (the mode number, final
byte, or first parameter, depending on the site) in a small fixed
table of 16 u32 slots, 64 bytes per site. Real streams only ever
produce a handful of distinct unsupported values per site, so if a
table fills, new values are suppressed too; by then the log
already shows the problem class and unbounded distinct values
would flood it anyway. Slots are claimed with 32-bit atomics
(native on wasm32) and never change afterwards, so lookups are a
lock-free scan and the worst case race is a duplicate message.
The OSC 1 change-icon message moves from info to warn to match the
other unsupported-input messages the helper covers.
Measured with ghostty-bench terminal-stream (2.6 GB real-session
corpus, 120x80, M4 Max, ReleaseFast, hyperfine means of 5 runs,
stderr to /dev/null which undersells the cost of a real log sink):
| stream | before | after | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 7.916 s | 7.674 s | +3.2% |
System time drops from 0.49 s to 0.22 s from the eliminated
writev calls.
clearCells released the style reference of every styled cell
individually: an array index, a ref decrement, and a liveness
check per cell. Styled cells overwhelmingly come in runs sharing
the same style id (a colored status bar, a highlighted region, a
full row painted in one color), so most of that work is repeated
bookkeeping on the same style entry.
This groups consecutive cells with the same style id and releases
each run with a single releaseMultiple call. Rows with alternating
styles degrade to the same per-cell cost as before; uniform rows,
the common case, do one ref-count update per run. The
releaseMultiple assertion that the ref count is at least the run
length holds by construction since every cell in the run held a
reference.
Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). The erase corpus paints a
full screen of styled rows and erases it with ED 2 in a loop,
which is the pattern full-screen TUIs produce on clear/redraw:
| stream | before | after | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 8.055 s | 7.965 s | +1.1% |
| styled paint + ED 2 (100 MB) | 260 ms | 123 ms | 2.1x |
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed printSliceFill as the single largest item (~25% of
total time), and disassembly showed the time split across three
scalar loops: the run-eligibility scan over codepoints, the
simple-cell check that guards the branch-free fill, and the general
path that fixes up style ref counts one cell at a time. The store
loop itself was already auto-vectorized by LLVM, but the two scans
are early-exit search loops that LLVM does not vectorize, and the
general path turns out to be the common case in real traffic:
styled text constantly overwrites cells holding a different style
(TUI redraws, scrolling colored output), so every such cell failed
the simple check and paid a release/use pair.
Three changes, which only pay off together (vectorizing the scans
without the bulk path makes mismatch-heavy rows slower because the
wider check re-runs for every cell the general path consumes):
The run-eligibility scan handles the narrow class, codepoints in
[0x10, 0xFF], eight lanes at a time. The simple-cell check compares
four masked cells per iteration. And a new bulk path handles runs
of cells that differ from the expected simple cell only by style
id: one vector scan finds the extent of the uniformly-styled run,
the ref counts are fixed with a single releaseMultiple/useMultiple
pair, and the cells are filled with the same branch-free store
loop as the simple case. Cells with graphemes, hyperlinks, or wide
content still fall back to print().
Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). The redraw corpus is a
full-screen 80-row styled repaint whose span color rotates every
frame, so every cell is overwritten with a different style:
| stream | before | after | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 8.826 s | 7.955 s | +11% |
| TUI redraw (100 MB) | 348 ms | 287 ms | +21% |
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~7% of time in nextNonUtf8 self, and most calls
were for the structural bytes of CSI sequences: the '[' after ESC
and the single byte spent in the csi_entry state (a digit, private
marker, or final byte). Real streams contain tens of millions of
CSI sequences, and each paid two to three function calls just to
advance the parser through those states before the bulk parameter
loop could take over.
This lifts both transitions into the consumeUntilGround loop: the
"ESC [" prefix is matched directly, and the csi_entry byte is
handled by a shared csiEntryByte helper that both the loop and
nextNonUtf8 use (the logic previously lived only in nextNonUtf8).
A typical CSI sequence now parses entirely within
consumeUntilGround/consumeCsiParams without any per-byte calls.
Handlers with a vtRaw hook keep the general path since csiEntryByte
dispatches finals directly.
Measured with ghostty-bench terminal-stream (120x80, M4 Max,
ReleaseFast, hyperfine means of 5 runs). nextNonUtf8 self time
drops from ~7% to ~3% of the profile:
| stream | before | after | change |
|----------------------------|---------|---------|--------|
| real 2.6 GB session corpus | 9.097 s | 8.854 s | +2.7% |
| csi mix (SGR/CUP, 100 MB) | 695 ms | 674 ms | +3.1% |
Profiling the csi benchmark showed ~20% of time in the style
ref-counted set (hash, probe, release/use churn) driven by
manualStyleUpdate, which runs after every SGR attribute even when
the attribute didn't actually change the cursor style. Real
programs re-assert the same style constantly (per span, per line,
or on every refresh of a mostly static screen), so a large share of
these updates are no-ops.
Screen.setAttribute already snapshots the old style to restore it
on failure, so this compares the style after applying the attribute
and returns early when it's unchanged: the current style ID is
already correct and no release/lookup/use is needed.
The tradeoff is one extra Style.eql on every style-changing
attribute. Measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs) across corpora with
different repeated style rates (the csi/sgr corpora draw random
colors from a palette so nearly every SGR changes the style, which
is the worst case for this change; the redraw corpora model TUI
refreshes that re-assert the current style for 70% / 95% of SGRs):
| stream | before | after | change |
|---------------------|--------|--------|--------|
| redraw (95% same) | 277 ms | 260 ms | +7% |
| redraw (70% same) | 302 ms | 291 ms | +4% |
| csi (~0% same) | 407 ms | 414 ms | -2% |
| sgr (~0% same) | 295 ms | 303 ms | -3% |
Real-world SGR traffic is far closer to the redraw corpora than to
the adversarial random-color ones, so this trades a small worst
case regression for a solid win on the common pattern.
After the CSI dispatch fast paths, profiling showed the remaining
escape-sequence cost was the per-byte plumbing itself: for every
parameter byte of a sequence like "ESC [ 38;2;10;20;30 m" the
stream re-entered nextNonUtf8, re-checked the parser state, and
re-dispatched through the fast-path switch, paying call and state
check overhead per digit.
consumeUntilGround now hands whole input slices to a new
consumeCsiParams loop whenever the parser is in the csi_param
state. It consumes runs of digits and separators with the parser
accumulator state held in locals, dispatches directly when it
reaches the final byte, and returns to the general path on the
first byte it doesn't understand (C0 controls, intermediates,
etc.), guaranteeing byte-for-byte identical semantics with the
per-byte fast path it hoists. Like the dispatch fast paths, this is
disabled at comptime for handlers that declare vtRaw so the
inspector continues to observe every action.
Throughput measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs):
| stream | before | after | change |
|--------|--------|--------|--------|
| csi | 525 ms | 407 ms | +29% |
| sgr | 414 ms | 294 ms | +41% |
Combined with the previous commit, CSI-heavy streams are 1.5-1.7x
faster end to end than before this series.
Profiling escape-heavy streams showed the dominant remaining cost
was Parser.next: every byte routed through it copies a [3]?Action
return value that is ~240 bytes (the action union is sized by
osc.Command). A typical CSI sequence paid this twice: once for the
first byte after "ESC [" (csi_entry has no fast path, so even the
first parameter digit went through the table machine) and once for
the final byte that dispatches the sequence.
This extends the existing stream fast paths to cover both. The
csi_param fast path now handles final bytes (0x40-0x7E) by
finalizing parameters and dispatching the CSI directly via a new
csiDispatchFinal, which replicates the parser's csi_dispatch action
(MAX_PARAMS overflow drop, trailing parameter finalization, and the
colon-separator validation for non-'m' finals) without constructing
the action array. A new csi_entry fast path handles the byte right
after "ESC [": first parameter digit, empty first parameter,
private markers (0x3C-0x3F), and parameterless finals. Everything
else (C0 controls, intermediates, the csi_entry colon edge case)
still defers to the state machine.
Because these paths dispatch without going through Parser.next,
they would bypass a handler's vtRaw hook, so they are disabled at
comptime for handlers that declare one (the inspector). Those
handlers keep the exact previous behavior.
Throughput measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs). The csi corpus is a
realistic mix of SGR, cursor movement, erases, and mode changes
with short text runs; sgr is a doom-fire-like stream of truecolor
SGRs and cell pairs:
| stream | before | after | change |
|--------|--------|--------|--------|
| csi | 618 ms | 525 ms | +18% |
| sgr | 486 ms | 414 ms | +17% |
#13209
After #13209 the IO pipeline delivers the parse thread's full
measured capacity, so IO throughput is now bound by VT processing.
Profiling `terminal-stream` on plain text showed ~85% of wall time
inside Terminal.print: every printable codepoint paid the full
per-character cost (right margin computation, grapheme clustering
checks, width lookup, wrap/insert mode checks, charset mapping,
per-cell style bookkeeping, dirty marking, cursor advance) even
though for typical bulk output every one of those answers is the
same for thousands of consecutive characters.
This adds a new print_slice stream action carrying a run of
printable codepoints, emitted whenever the SIMD ground-state path
decodes multiple codepoints at once, plus Terminal.printSlice which
processes such runs in batch. Since action dispatch is comptime,
delivering a slice through the existing vt handler interface has
the same codegen as a dedicated entry point; handlers that don't
care about batching can simply loop and treat each codepoint as a
print action.
printSlice hoists all run-invariant checks (status display, insert
and wraparound modes, charset state, hyperlink state) out of the
loop and then fills cells row by row. A single masked u64 compare
classifies each destination cell as "simple" (plain codepoint cell,
narrow, no hyperlink, style already matching the cursor); runs of
simple cells are written with a branch-free store loop, style-only
mismatches are handled inline with the same ref-counting printCell
does, and anything needing real cleanup (wide spacers, grapheme
data, hyperlinks) exits the fast path with the cursor positioned on
the offending cell so print() handles that one codepoint with full
generality. Dirty marking, previous_char, and cursor advancement
happen once per row instead of once per character.
The fast path handles both narrow and wide codepoints (CJK/emoji are
written as wide+spacer_tail pair fills, including spacer-head
handling at the right edge) and stays exact under grapheme
clustering (mode 2027): a codepoint only joins a run if it is width
1 or 2 and is a grapheme break from the previously written
codepoint, so print() would never have attached it to the previous
cell. The first codepoint of a batch defers to print() whenever the
previous cell could carry cluster state we can't cheaply reason
about (including a pending wrap, where print attaches to the
pending cell instead of wrapping).
Correctness is verified by a new differential fuzz test that runs
the same operations through per-codepoint print and randomly
chunked printSlice, comparing full screen dumps, cursor state, and
page integrity (style refcounts, grapheme maps) after every
operation, across wraps, margins, mode toggles, hyperlinks,
charsets, and wide/combining/ZWJ/RI/jamo codepoints.
Throughput measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs; ~15ms process startup
included in all numbers):
| stream | before | after | change |
|---------------------------|--------|--------|--------|
| ascii (no newlines) | 784 ms | 138 ms | 5.7x |
| ascii lines | 833 ms | 198 ms | 4.2x |
| unicode mixed-script | 779 ms | 320 ms | 2.4x |
| CJK (all wide) | 424 ms | 126 ms | 3.4x |
| unicode, mode 2027 on | 807 ms | 367 ms | 2.2x |
| CJK, mode 2027 on | 495 ms | 198 ms | 2.5x |
Embedders that render theme editors, palette pickers, or custom settings
UI need to use the same color semantics as Ghostty.
This moves the shared parsing paths into terminal/color and exposes them
through libghostty-vt. Config color and palette parsing now delegate to
the same helpers, so CLI/config behavior and the C ABI stay in lockstep.
From C:
GhosttyColorRgb rgb;
ghostty_color_parse("ForestGreen", 11, &rgb);
uint8_t index;
ghostty_color_parse_palette_entry(
"0x10=#282c34", 12, &index, &rgb);
const GhosttyColorX11Entry* names =
ghostty_color_x11_names();
The exported color API is:
ghostty_color_parse
ghostty_color_parse_x11
ghostty_color_parse_palette_entry
ghostty_color_palette_default
ghostty_color_palette_generate
ghostty_color_luminance
ghostty_color_perceived_luminance
ghostty_color_contrast
ghostty_color_x11_names
ghostty_color_x11_name_count
The X11 name table is parsed once at comptime into null-terminated
entries in rgb.txt order. The existing case-insensitive map keeps the
same behavior for RGB.parse and +list-colors, while bindings can walk a
static table without allocations.
This doesn't add any more binary size since all of this was already used
by terminal internals.