Fixes a frame time regression reported with fortio's `fps -fire`
benchmark (fortio.org/terminal/fps): frame times nearly doubled at
typical grid sizes after #13209 (the pipelined pty reader), and were
more than 5x worse at small grids.
## The problem
The `fps -fire` program follows a request/response pattern: write a
frame, end it with a cursor position query (CSI 6n), and block until the
reply arrives before starting the next frame.
The gather stage treats any burst of 1 KiB or more as a saturated
stream. When its spin retries come up dry, it sleeps in a 1ms poll
expecting the writer's next refill so it can publish fewer, larger
batches. But a frame-synced writer will never refill here: it is blocked
waiting for a reply to a query that is sitting inside the very batch the
gather stage is holding back. The poll always sleeps its full timeout,
adding ~1.2ms to every round trip. Ouch!
## The fix
Sleeping for a refill gap is only free while the parse stage is busy,
since the wait hides behind parse time. Once the parser is idle, every
additional microsecond spent bridging is added straight to output
latency. So:
1. When the spin retries exhaust and the parse stage is idle, deliver
the batch immediately instead of polling.
2. When the parse stage is busy, use a `pipe2` pipe to allow the parser
to notify the gather thread it is idle. In the middle of the `poll` loop
and sleep, we can get interrupted immediately and deliver the batch.
The pipe is only written while the gather stage is actively polling, so
an interactive terminal never pays the syscalls, and a saturated stream
never idles the parser, preserving full batching and throughput for bulk
output. Win, win, win!
## Benchmarks
| workload | pre-#13209 | main | this PR |
|---|---|---|---|
| fps -fire 80x24 | 0.262 ms / 3674 fps | 1.435 ms / 694 fps | 0.234 ms
/ 4106 fps |
| fps -fire 160x45 | 1.012 ms / 975 fps | 1.823 ms / 545 fps | 0.701 ms
/ 1405 fps |
| fps -fire 284x68 | 2.443 ms / 407 fps | 2.391 ms / 414 fps | 1.351 ms
/ 732 fps |
| cat 19.3 MB | 0.204 s (95 MB/s) | 0.086 s (224 MB/s) | 0.082 s (236
MB/s) |
## LLM Notes
Bisect script written by hand and found the offending commit. Fable 5
found the likely cause. I manually came up with the proposed solution
and wrote it out. Fable helped benchmark for me to verify my assumptions
conceptually and in the real world. Commit message written by hand
except for the benchmark results.
Fixes a frame time regression reported with fortio's `fps -fire`
benchmark (fortio.org/terminal/fps): frame times nearly doubled at
typical grid sizes after #13209 (the pipelined pty reader), and were
more than 5x worse at small grids.
## The problem
The `fps -fire` program follows a request/response pattern: write a
frame, end it with a cursor position query (CSI 6n), and block until
the reply arrives before starting the next frame.
The gather stage treats any burst of 1 KiB or more as a saturated
stream. When its spin retries come up dry, it sleeps in a 1ms poll
expecting the writer's next refill so it can publish fewer, larger
batches. But a frame-synced writer will never refill here: it is
blocked waiting for a reply to a query that is sitting inside the
very batch the gather stage is holding back. The poll always sleeps
its full timeout, adding ~1.2ms to every round trip. Ouch!
## The fix
Sleeping for a refill gap is only free while the parse stage is busy,
since the wait hides behind parse time. Once the parser is idle,
every additional microsecond spent bridging is added straight to
output latency. So:
1. When the spin retries exhaust and the parse stage is idle,
deliver the batch immediately instead of polling.
2. When the parse stage is busy, use a `pipe2` pipe to allow the parser
to notify the gather thread it is idle. In the middle of the `poll`
loop and sleep, we can get interrupted immediately and deliver
the batch.
The pipe is only written while the gather stage is actively polling, so an
interactive terminal never pays the syscalls, and a saturated stream
never idles the parser, preserving full batching and throughput for
bulk output. Win, win, win!
## Benchmarks
| workload | pre-#13209 | before | after |
|---|---|---|---|
| fps -fire 80x24 | 0.262 ms / 3674 fps | 1.435 ms / 694 fps | 0.234 ms / 4106 fps |
| fps -fire 160x45 | 1.012 ms / 975 fps | 1.823 ms / 545 fps | 0.701 ms / 1405 fps |
| fps -fire 284x68 | 2.443 ms / 407 fps | 2.391 ms / 414 fps | 1.351 ms / 732 fps |
| cat 19.3 MB | 0.204 s (95 MB/s) | 0.086 s (224 MB/s) | 0.082 s (236 MB/s) |
## LLM Notes
Bisect script written by hand and found the offending commit. Fable 5
found the likely cause. I manually came up with the proposed solution and
wrote it out. Fable helped benchmark for me to verify my assumptions
conceptually and in the real world.
The pipelined POSIX pty reader uses multiple large gather buffers to
avoid stalling on Darwin, where pty reads are capped around 1KiB. On
Linux this can let bulk terminal producers run too far ahead of terminal
parsing/rendering.
Frame-style terminal apps such as DOOM-fire can then report very high
producer FPS while Ghostty displays stale frames from the buffered
stream.
Keep the Darwin-tuned 4 x 64KiB pipeline, but reduce non-Darwin
read-ahead to 2 x 8KiB so the pty can apply backpressure before multiple
frames are queued.
AI disclosure: Codex gpt-5.5 xhigh helped.
The pipelined POSIX pty reader uses multiple large gather buffers to avoid
stalling on Darwin, where pty reads are capped around 1KiB. On Linux this can
let bulk terminal producers run too far ahead of terminal parsing/rendering.
Frame-style terminal apps such as DOOM-fire can then report very high producer
FPS while Ghostty displays stale frames from the buffered stream.
Keep the Darwin-tuned 4 x 64KiB pipeline, but reduce non-Darwin read-ahead to
2 x 8KiB so the pty can apply backpressure before multiple frames are queued.
This optimizes scrolling inside a scroll region (DECSTBM).
We're squeezing blood out of a rock here. There just isn't much
improvement to be had given our architecture for this case, but there
were a couple things found that result in modest gains.
## Vtebench
You can see the modest gains here. Still behind Alacritty (very very
close).
<img width="3558" height="2410" alt="CleanShot 2026-07-06 at 21 18
39@2x"
src="https://github.com/user-attachments/assets/3c800089-3510-4887-9872-e9926d686955"
/>
## 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 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.
The only difference of getting the string contents between dragging
pasteboard and general pasteboard is the URL now. It was first
introduced in #4962, I don't why it was added, since `public.url` often
comes with `public.utf8-plain-text` when dragging.
I also check with iTerm's
[PTYTextView](f267f243e5/sources/TerminalView/PTYTextView.m (L307)),
it doesn't register URL either, so I think it's safe for us to remove
it.
> I checked the iTerm's source after my changes, I hope that doesn't
violates any licensing
The `cursor-height` metric (and corresponding `adjust-`) was introduced
by #3062 but the sprite face rework in #7732 accidentally removed the
logic that it relied on. I've moved the logic to live inside of the
sprite face itself (which, I think, was my plan while writing the
rework, I just forgot to actually do it lol), and added a test for the
height metric being respected and the re-centering being performed
correctly.
This problem came to my attention because of #13221, which didn't go
about doing the fix the right way, but did make me realize that it was a
problem in the first place (since I had thought that I had already
implemented this logic when doing the rework!)
### Verification
https://github.com/user-attachments/assets/4074690b-846e-442d-8ec0-91a34042f6eb
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. This was
already a super fast part of Ghostty, so I don't expect any noticeable
changes for end users or library users. But, why not be fast?
## 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.
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.
This is a series of seven commits that optimizes VT processing
throughput. Each commit is isolated, individually benchmarked, and
carries a detailed commit message so please read each for details about
each change.
Whereas #13220 was driven by synthetic data, this series was driven by
profiling a 2.6 GB recording of real terminal sessions from an asciinema
data dump. Through this, I've been able to improve throughput processing
the full dump from 276 to 342 MB/s on my system.
> [!NOTE]
>
> **LLM usage:** This series of work was largely driven by Fable 5 and
the summaries below started as LLM-written. I've proofread (and mostly
modified) every line of work and rewritten everything to be shorter and
more in line with how I'd describe a change. Nothing here was
unreviewed. I also threw away 3 sets of changes I didn't agree with the
maintenance of, but did speed up things a bit.
## The changes
1. **decode ASCII inline in the SIMD scan for ESC**. Real streams call
`utf8DecodeUntilControlSeq` on short runs (an escape every ~18 bytes),
so ~9% of total time was simdutf setup plus its scalar tail paid per
tiny run. The ESC scan and the UTF-8 to UTF-32 "decode" (a widening for
ASCII) are now one pass. **Result: +5.4% on the real corpus.**
2. **handle CSI entry bytes inline in consumeUntilGround**. The `[`
after ESC and the single `csi_entry` byte each paid a `nextNonUtf8`
call, two to three calls for every one of the tens of millions of CSI
sequences in the recording. Both transitions are now handled in the
consumeUntilGround loop, so a typical CSI parses with no per-byte calls
at all. **Result: +2.7% real corpus, +3.1% CSI-heavy stream.**
3. **fill style-only cell runs in bulk in printSliceFill**. The largest
single item in the profile (~25%). The print fast path's two scans (run
eligibility, simple-cell check) are early-exit search loops LLVM won't
vectorize, and real traffic constantly lands in the general path because
styled text overwrites cells styled differently (TUI redraws), paying a
per-cell release/use pair. The scans are now vectorized and
uniformly-styled runs are consumed wholesale: one vector scan, one
releaseMultiple/useMultiple pair, one branch-free fill. **Result: +11%
real corpus, +21% TUI redraw.**
4. **release style refs per run instead of per cell in clearCells**.
Erasing styled rows released each cell's style reference one at a time
even though styled cells overwhelmingly share one style per run (status
bars, highlighted regions, solid rows). Runs now release with a single
releaseMultiple. **Result: +1.1% real corpus, 2.1x on full-screen styled
erase.**
5. **log unsupported-input messages once per distinct value**. The
recording triggers ~120k warnings, dominated by a few messages some
program re-emitted every frame ("unimplemented mode: 34"), each paying
formatting plus a blocking writev while adding nothing beyond the first
occurrence. A logUnsupportedOnce helper suppresses repeats per (call
site, value) using a 64-byte lock-free table per site. **Result: +3.2%
on the real corpus, system time halved.**
## Benchmarks
Measured with `ghostty-bench +terminal-stream` (full terminal handler,
120x80 terminal, M4 Max, macOS 26, ReleaseFast, hyperfine means of 6
runs, 64KiB read chunks). These are parser-stage numbers, not end-to-end
app numbers.
| stream | before | after | throughput | change |
|-------------------------------|---------|---------|------------------|--------|
| real 2.6 GB session recording | 9.441 s | 7.609 s | 276 → 342 MB/s |
1.24x |
| ascii (no escapes) | 119 ms | 84 ms | 838 → 1186 MB/s | 1.41x |
| TUI redraw (rotating styles) | 417 ms | 293 ms | 240 → 342 MB/s |
1.42x |
| styled paint + ED 2 erase | 418 ms | 124 ms | 239 → 808 MB/s | 3.38x |
| csi mix (random-color SGR/CUP)| 695 ms | 696 ms | (adversarial) |
~1.0x |
Note the "csi mix" benchmark above was a generated adversarial input
e.g. a worst-case input for the changes we made. It wasn't based in
real-world data or expectations. But I asked for it to be done so we can
verify we don't see regressions too much (and were able to verify we see
basically none).
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 terminal-stream on a 2.6 GB recording of real terminal
sessions showed ~9% of total time inside the UTF-8 decode stage,
and most of it was not the decode itself: real streams contain an
escape sequence every ~18 bytes, so utf8DecodeUntilControlSeq is
called on short printable runs, and each call paid simdutf setup
plus its scalar rewind_and_convert_with_errors tail (which handles
the last partial SIMD block of every conversion) for only a
handful of bytes. The scalar tail alone accounted for ~3.4% of
total time.
Terminal input is also overwhelmingly ASCII, for which UTF-8 to
UTF-32 "decoding" is just widening each byte to 32 bits. This
fuses the two passes: while scanning each chunk for ESC we also
check for bytes >= 0x80 and widen pure-ASCII chunks straight into
the output vector via PromoteTo, never touching simdutf. The first
non-ASCII byte hands the remainder of the run (up to the next ESC)
to the existing simdutf-based path, so non-ASCII text takes
exactly the same code as before. Inputs shorter than one vector
are handled by a scalar byte loop that likewise skips simdutf for
ASCII.
The widening store needs a dedicated path for the HWY_SCALAR
fallback target (compiled on targets without guaranteed SIMD, e.g.
arm-linux-androideabi): its single-lane vectors cannot be halved
so the one lane is widened directly.
The new differential fuzz test verifies the SIMD implementation
still matches the scalar reference exactly. Measured with
ghostty-bench terminal-stream (2.6 GB real-session corpus, 87%
printable ASCII / 5.5% ESC / 5.6% UTF-8, 120x80, M4 Max,
ReleaseFast, hyperfine means):
| stream | before | after | change |
|-------------------|-----------------|-----------------|--------|
| real 2.6 GB corpus | 9.582 s (272 MB/s) | 9.090 s (287 MB/s) | +5.4% |
The scalar fallback of utf8DecodeUntilControlSeq (used when SIMD is
disabled, e.g. wasm builds) treated a valid-so-far but incomplete
UTF-8 sequence at the end of its decode region as pending input in
all cases: it stopped without consuming the bytes so a future chunk
could complete the sequence. That is correct when the region ends
at the end of the input, but the region can also be bounded by an
ESC byte. In that case the sequence can never be completed (the
next byte is already known to be ESC), and the SIMD implementation,
via simdutf, replaces the ill-formed prefix with U+FFFD and
consumes up to the ESC. The two implementations disagreed on both
the consumed count and the decoded output for inputs like
"\xC2\x1B[0m".
The divergence is invisible at the stream level (the pending bytes
take the scalar nextUtf8 path which also emits a replacement
character once it sees the ESC) but it means the scalar decoder is
not a faithful reference for the SIMD one.
This makes the scalar decoder treat a partial sequence bounded by
an ESC as a maximal subpart per Unicode 3-7: one U+FFFD, consumed
through the end of the region. Truncation at the true end of input
still leaves the bytes pending. Also adds a differential fuzz test
that runs 10k random mixtures of ASCII, escapes, controls, and
valid/invalid UTF-8 through both implementations and requires
identical results, which is what caught this.
The terminal-stream benchmark fed the stream in 4KiB chunks while
the real IO thread reads from the pty into 64KiB buffers (see
buffer_capacity in termio/Exec.zig) and hands those to the stream
whole. Chunk size affects measurement in two ways: it determines
how often the stream crosses a chunk boundary (partial UTF-8
sequences, escape sequences split mid-parse) and how many read
syscalls the harness itself performs (a 2.6 GB corpus is ~636k
pread calls at 4KiB versus ~40k at 64KiB).
This bumps the benchmark read and dispatch buffers to 64KiB so the
stream is exercised with realistic chunk sizes. Measured with
ghostty-bench terminal-stream on a 2.6 GB recording of real
terminal sessions (120x80, M4 Max, ReleaseFast, hyperfine means):
| harness | time | throughput |
|--------------|-----------------|------------|
| 4KiB chunks | 9.651 s ± 0.013 | 270 MB/s |
| 64KiB chunks | 9.582 s ± 0.101 | 272 MB/s |
The stream itself is barely chunk-size sensitive (most time is in
parsing and terminal state updates), but the harness now matches
what the IO thread actually does, and later commits are measured
against this configuration.
This is a series of five commits that optimizes VT processing throughput
in various ways. Each commit is isolated, individually benchmarked, and
carries a detailed commit message so please read each for details about
each change.
After #13209 made IO fully parser-bound, these gains should translate
directly into end-to-end IO throughput (until some other stage becomes
the new bottleneck). Plain ASCII processing went from ~128 MB/s to ~725
MB/s. `time cat ascii_150MB.txt` went from 1.5s before 13209 to 1.2s on
main to 566ms on this branch.
## The changes
1. **batch printed codepoint runs into direct row fills**. Profiling
showed ~85% of plain-text time inside `Terminal.print`, re-answering the
same questions (margins, modes, width, charset, style) for every single
character. A new `print_slice` stream action delivers runs of decoded
codepoints to `Terminal.printSlice`, which hoists the invariants and
fills rows with a masked-compare + branch-free store loop, falling back
to `print()` for anything complex. **Result: 2.2x–5.7x on ascii plus
unicode text.**
2. **dispatch CSI finals directly from stream fast paths**. Every byte
through `Parser.next` copies a ~240 byte `[3]?Action` and a typical CSI
copied it twice. New `csi_entry/final` fast paths dispatch directly
without the action array. **Result: +17-18% on CSI streams.**
3. **bulk-parse CSI parameter bytes at the slice level**. Parameter
digits/separators are consumed in a tight slice loop with parser state
in locals instead of re-entering the per-byte path. **Result: +29-41% on
escape-heavy streams.**
4. **skip style map update when SGR leaves style unchanged**. Skip the
release/hash/probe/use churn when an SGR attribute is a no-op. **Result:
+4-7% on TUI-refresh patterns, -2-3% on adversarial random-color
streams** (tradeoff detailed in the commit message). This one is more
questionable, but willing to measure on real workloads.
## Benchmarks
Measured with `ghostty-bench +terminal-stream` (full terminal handler,
100 MB deterministic synthetic corpora, 120x80 terminal, M4 Max, macOS
26, ReleaseFast, hyperfine means of 10 runs, ~15 ms process startup
included in all numbers). These are parser-stage numbers, not end-to-end
app numbers.
| stream | before | after | throughput | change |
|----------------------------|--------|--------|------------------|--------|
| ascii (no newlines) | 784 ms | 138 ms | 128 → 725 MB/s | 5.7x |
| ascii lines | 833 ms | 198 ms | 120 → 505 MB/s | 4.2x |
| unicode mixed-script | 779 ms | 320 ms | 128 → 313 MB/s | 2.4x |
| CJK (all wide) | 424 ms | 126 ms | 236 → 794 MB/s | 3.4x |
| unicode, mode 2027 on | 807 ms | 367 ms | 124 → 273 MB/s | 2.2x |
| CJK, mode 2027 on | 495 ms | 198 ms | 202 → 505 MB/s | 2.5x |
| csi mix (SGR/CUP/EL/modes) | 648 ms | 414 ms | 154 → 242 MB/s | 1.6x |
| sgr fire (doom-fire-like) | 495 ms | 303 ms | 202 → 330 MB/s | 1.6x |
| TUI redraw (repeat styles) | 642 ms | 291 ms | 156 → 344 MB/s | 2.2x |
| osc | 8.26 s | 8.20 s | (untouched path) | ~1.0x |
**End-to-end note:** #13209 measured the parse thread pegged while the
gather thread used ~33% of a core, so parser gains of this size may make
gather (or the renderer lock) the new bottleneck for plain text before
the full 5.7x shows up end to end. I'll take a look at that soon...
## LLM Notes
These findings were almost all found by Fable 5. I went through each
change and simplified quite a lot, read every single line, re-ran
verifications by hand. Fable in particular isn't good at writing elegant
Zig code, so there's a lot of style stuff. Ultimately though, I
understand all of this and feel comfortable with the changes.
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 |
The terminal-stream benchmark previously used a simplified handler
that handles print actions and drops everything else. That was
originally intended to isolate parse and print throughput, but it
understates the cost of escape-heavy streams: no terminal state is
updated for CSI/OSC/ESC sequences, and because actions are
dispatched at comptime, the unhandled action arms are eliminated
entirely, so the benchmark measures dispatch code that doesn't
exist in the real app.
This switches the benchmark to the full readonly terminal stream
handler (terminal.TerminalStream). Every escape sequence now
updates real terminal state (styles, cursor movement, erases,
modes, etc.), closely mirroring the work the real IO thread does
per byte. This is the handler used to measure the VT throughput
changes in the following commits.
Parser-in-isolation measurement remains covered by the separate
terminal-parser and osc-parser benchmarks, and print throughput is
identical under both handlers since printing flows into the same
Terminal call either way.
This replaces the single-threaded pty read loop on posix systems with a
two-stage pipeline: a new `io-gather` thread drains the pty into a small
ring of large buffers while the existing `io-reader` thread parses the
previous batch concurrently.
After this, our IO throughput is now within noise (1 to 3%) of our VT
parsing and processing throughput. This means that any VT performance
improvements should raise IO throughput. We're completely bound by it
now.
> [!IMPORTANT]
>
> **Against all odds, we've found a massive (25% to 55%) performance
improvement in Ghostty IO.** This is a subsystem we were happy to get 2%
improvement gains in recent years. And the change is relatively simple
and understandable, too.
The motivating discovery was actually found by Fable, but the resulting
code was hand-written and hand-verified (in addition to model-verified
as an extra check): on macOS the kernel tty output queue caps every read
on the pty master at about 1 KiB regardless of the read buffer size.
Instrumenting a pty with a 64 KB buffer while streaming a 6.49 MB file
produced 6,337 reads where every read was exactly 1024 bytes.
This immediately made me realize two things about the old loop that
we've had since like 2023 which called processOutput once per read: all
per-call overhead (terminal lock, render wakeup, cursor timestamp) was
paid per kilobyte of bulk output, and the child could never run more
than 1 KiB ahead of us, so while we parsed the child (e.g. `cat`) sat
blocked on a full kernel queue instead of producing.
In 2023, I justified this architecture by saying "reads are generally
small" but I didn't understand then that reads are generally small
because the kernel makes them small even if there is a lot of data.
To preserve latency for the more typical
small-reads-that-are-actually-small, sub-1 KB payloads deliver on the
first EAGAIN with no added latency.
## Benchmarks
End-to-end throughput was measured by timing `cat file > /dev/ttysN`
against a fresh app instance (M4 Max, macOS 26, ReleaseFast, medians of
repeated interleaved A/B runs):
| stream | before | after | change |
|-------------------------|--------------|---------------|---------|
| ascii.txt (6.5 MB) | 91-92 MB/s | 114-123 MB/s | +25-30% |
| unicode.txt (8 MB) | 116-117 MB/s | 180-183 MB/s | +55% |
| DOOM-Fire-Zig | 530 fps | 770 fps | +45% |
The pipeline now delivers the parse stage's full measured capacity (the
parse thread is pegged while gather spends ~33% of a core, so any IO
throughput improvements are now fully parser-bound).
**Linux note:** This needs to be verified on Linux. I think broadly
architecture is better and should never be worse. But its possible some
of the magic constants need to be tuned differently. Would love more
testing there.
## Some Background on LLM Usage
As noted above, the original promising path was found by Fable 5. I
decided to throw some money at analyzing our IO throughput, and went
into it expecting minor improvements at the cost of unmaintainable
special case optimizations (typical LLM results when given such tasks on
an already-optimized path).
I spun off a Fable 5 session (API pricing) prior to some holiday weekend
(American, July 4th) festivities. When I came back late that night, it
pointed this path out with some pretty questionable code/results.
The frustrating thing is that _I swear I tried this years ago_ and it
didn't deliver results. Unfortunately, it was long enough ago (probably
2023) that I can't remember nor do I have any evidence. But, my brain
had already clocked this possibility and blocked it out as "already
tried and failed." The code in question that this PR ultimately touched
has been basically unchanged for 2+ years.
As a second point, this is a **great example of what I love about
LLMs**. I was not in a performance-hunting mood and I have other tasks I
need to get to while at the computer, so improving Ghostty performance
wasn't my current mode and I would not have worked on it anytime soon
while at the computer. But, I try to keep an agent running all the time,
and before my family came over for holiday afternoon, I decided today I
would try budgeting $100 to Fable to focus on Ghostty's IO performance.
Why not?
I came back, saw some questionable but interesting results, and decided
it was worth some human hours to validate, understand, and work on this
myself. It was worth it. 😄
Anyways, the total API cost of this if you're curious: **$88.28**.
Remember, I **hand-wrote the code** so thats basically what it cost me
to discover this path. Fable did produce its own solution (in about 3x
the LoC change of this diff with non-idiomatic Zig styles, overly
defensive guards, and simultaneously poor error handling). So, I guess I
did pay for a solution. A bad one. Haha. But the problem it found was
real and good.
This replaces the single-threaded pty read loop on posix systems with
a two-stage pipeline: a new `io-gather` thread drains the pty into a
small ring of large buffers while the existing `io-reader` thread
parses the previous batch concurrently.
The motivating discovery was actually found by Fable, but the
resulting code was hand-written and hand-verified (in addition to
model-verified as an extra check): on macOS the kernel tty output queue
caps every read on the pty master at about 1 KiB regardless of the
read buffer size. Instrumenting a pty with a 64 KB buffer while
streaming a 6.49 MB file produced 6,337 reads where every read was exactly
1024 bytes.
This immediately made me realize two things about the old loop that we've
had since like 2023 which called processOutput once per read: all per-call
overhead (terminal lock, render wakeup, cursor timestamp) was paid per
kilobyte of bulk output, and the child could never run more than 1 KiB
ahead of us, so while we parsed the child (e.g. `cat`) sat blocked on a full
kernel queue instead of producing.
In 2023, I justified this architecture by saying "reads are generally small"
but I didn't understand then that reads are generally small because the
kernel makes them small even if there is a lot of data.
To preserve latency for the more typical
small-reads-that-are-actually-small, sub-1 KB payloads deliver on the first
EAGAIN with no added latency.
End-to-end throughput was measured by timing `cat file > /dev/ttysN`
against a fresh app instance (M4 Max, macOS 26, ReleaseFast, medians
of repeated interleaved A/B runs):
| stream | before | after | change |
|-------------------------|--------------|---------------|---------|
| ascii.txt (6.5 MB) | 91-92 MB/s | 114-123 MB/s | +25-30% |
| unicode.txt (8 MB) | 116-117 MB/s | 180-183 MB/s | +55% |
| DOOM-Fire-Zig | 530 fps | 770 fps | +45% |
The pipeline now delivers the parse stage's full measured capacity
(the parse thread is pegged while gather spends ~33% of a core, so
any IO throughput improvements are now fully parser-bound).
**Linux note:** This needs to be verified on Linux. I think broadly
architecture is better and should never be worse. But its possible
some of the magic constants need to be tuned differently. Would love
more testing there.
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.
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.
Add a shared encoder for CSI ? 997 ; Ps n color scheme reports and use
it for both CSI ? 996 n replies and unsolicited Termio reports. Export
the same encoder through the libghostty-vt C API with docs and an
example.
This is a really light API, arguably easy for consumers to hardcode, but
it didn't match the rest of our style in the libghostty API so we should
expose it.
This PR fixes#7984. The issue was that GTK would clamp the window
itself based on the display it was opened on. We fix this by computing
the size based on the current display and then implicitly setting the
window size instead of relying on GTK to do it.
Claude Code w/ Opus 4.7 was used to investigate, fix and explain some of
the Ghostty architecture to me.
Add a shared encoder for CSI ? 997 ; Ps n color scheme reports and use
it for both CSI ? 996 n replies and unsolicited Termio reports. Export the
same encoder through the libghostty-vt C API with docs and an example.
This is a really light API, arguably easy for consumers to hardcode,
but it didn't match the rest of our style in the libghostty API so we
should expose it.
Example: GHOSTTY_COLOR_SCHEME_DARK encodes to ESC [ ? 997 ; 1 n,
while GHOSTTY_COLOR_SCHEME_LIGHT encodes to ESC [ ? 997 ; 2 n.
Embedders that render text outside the terminal grid need to predict how
many cells text will occupy once it is written to the terminal. The
existing codepoint width API exposes the table used by print, but that
is not enough for mode 2027 grapheme clustering: VS15/VS16, ZWJ
sequences, skin tone modifiers, and other continuation codepoints can
change the width of the whole cluster.
This exposes a single segment-and-measure API so callers use Ghostty
segmentation and width folding together:
uint8_t width;
size_t n = ghostty_unicode_grapheme_width(cps, len, &width);
From the Zig module:
const vt = @import("ghostty-vt");
const result = vt.unicode.graphemeWidth(u21, cps);
Callers loop until their string is consumed. The API is intentionally
not streaming: input must contain a complete first cluster or the
logical string end, so chunked readers should keep buffering when the
function consumes all available codepoints and more may arrive.
The terminal hot path now shares the width-decision func with the API,
the helper is inline and preserves the old branch structure. So this
doesn't change codegen at all.
Embedders that render text outside the terminal grid need to predict
how many cells text will occupy once it is written to the terminal.
The existing codepoint width API exposes the table used by print, but
that is not enough for mode 2027 grapheme clustering: VS15/VS16, ZWJ
sequences, skin tone modifiers, and other continuation codepoints can
change the width of the whole cluster.
This exposes a single segment-and-measure API so callers use Ghostty
segmentation and width folding together:
uint8_t width;
size_t n = ghostty_unicode_grapheme_width(cps, len, &width);
From the Zig module:
const vt = @import("ghostty-vt");
const result = vt.unicode.graphemeWidth(u21, cps);
Callers loop until their string is consumed. The API is intentionally
not streaming: input must contain a complete first cluster or the
logical string end, so chunked readers should keep buffering when the
function consumes all available codepoints and more may arrive.
The terminal hot path now shares the width-decision func with the
API, the helper is inline and preserves the old branch structure. So
this doesn't change codegen at all.
(#12852)
I opened a discussion to work on the new kitty dnd protocol and
implementing it for Ghostty. I was told to work on the parser but not to
hook up any actions to it yet. So, that's what I did! Largely based the
format on kitty_clipboard_protocol.zig, and used Claude Opus 4.8 (Claude
Code) for writing tests and some structural guidance early on. Would
love to get started on adding actions as well!
This adds a GHOSTTY_SCROLL_VIEWPORT_ROW tag with a `size_t row` member
in the value union. The row is an absolute offset from the top of the
scrollable area, clamped to the active area, in the same row space as
the scrollbar offset so thumb positions round-trip cleanly:
ghostty_terminal_scroll_viewport(term,
(GhosttyTerminalScrollViewport){
.tag = GHOSTTY_SCROLL_VIEWPORT_ROW,
.value = {.row = 42},
});
The tag is appended to the existing enum and the union fits within the
reserved padding, so this is ABI compatible.
This also corrects the docs on GHOSTTY_TERMINAL_DATA_SCROLLBAR: the
getter is amortized O(1) (total is maintained incrementally, the offset
is cached), not "expensive". Since there is intentionally no change
callback, the docs now bless polling per frame or per write batch and
diffing, which is what Ghostty's own renderer does.
Motivation: Embedders building native scrollbars can already read scroll
state via GHOSTTY_TERMINAL_DATA_SCROLLBAR, but the write side only
exposed top/bottom/delta scrolling. Mapping a scrollbar thumb drag to an
absolute position required reading the current offset and computing a
delta, which is two calls that must be sequenced atomically by the
caller.
The core already supports absolute positioning and the macOS app uses it
for scroller drags via the scroll_to_row keybinding; this exposes the
same operation through the libghostty C API.
Embedders that render text outside the terminal grid need to predict how
many cells a codepoint will occupy once it is written to the terminal.
The immediate motivation is IME preedit overlay rendering: measuring
preedit text with font APIs (e.g. CoreText advances) can disagree with
the terminal's unicode table on ambiguous-width CJK and emoji, causing
the overlay to visibly jump when the composed text commits and reflows
through the real grid layout.
This exposes the exact width table the terminal print path already uses,
so overlays are column-accurate by construction. From C:
uint8_t w = ghostty_unicode_codepoint_width(0x4E00); // 2
And from the Zig module:
const vt = @import("ghostty-vt");
const w = vt.unicode.codepointWidth(0x4E00); // 2
The function is total over its input: 0 for zero-width codepoints
(controls, combining marks, default-ignorables, surrogates), 2 for wide
codepoints (East Asian Wide/Fullwidth, regional indicators, clamped at
2), and 1 for everything else, including invalid values beyond U+10FFFF.
Perf: uses the LUT lookup we use for the main core terminal
Binary size: the width table was already linked into libghostty-vt via
the print path, so this adds only the exported wrapper.
This adds a GHOSTTY_SCROLL_VIEWPORT_ROW tag with a `size_t row` member
in the value union. The row is an absolute offset from the top of the
scrollable area, clamped to the active area, in the same row space as
the scrollbar offset so thumb positions round-trip cleanly:
ghostty_terminal_scroll_viewport(term,
(GhosttyTerminalScrollViewport){
.tag = GHOSTTY_SCROLL_VIEWPORT_ROW,
.value = {.row = 42},
});
The tag is appended to the existing enum and the union fits within the
reserved padding, so this is ABI compatible.
This also corrects the docs on GHOSTTY_TERMINAL_DATA_SCROLLBAR: the
getter is amortized O(1) (total is maintained incrementally, the offset
is cached), not "expensive". Since there is intentionally no change
callback, the docs now bless polling per frame or per write batch and
diffing, which is what Ghostty's own renderer does.
Motivation: Embedders building native scrollbars can already read scroll state via
GHOSTTY_TERMINAL_DATA_SCROLLBAR, but the write side only exposed
top/bottom/delta scrolling. Mapping a scrollbar thumb drag to an
absolute position required reading the current offset and computing a
delta, which is two calls that must be sequenced atomically by the
caller.
The core already supports absolute positioning and the macOS
app uses it for scroller drags via the scroll_to_row keybinding; this
exposes the same operation through the libghostty C API.
Embedders that render text outside the terminal grid need to predict
how many cells a codepoint will occupy once it is written to the
terminal. The immediate motivation is IME preedit overlay rendering:
measuring preedit text with font APIs (e.g. CoreText advances) can
disagree with the terminal's unicode table on ambiguous-width CJK and
emoji, causing the overlay to visibly jump when the composed text
commits and reflows through the real grid layout.
This exposes the exact width table the terminal print path already
uses, so overlays are column-accurate by construction. From C:
uint8_t w = ghostty_unicode_codepoint_width(0x4E00); // 2
And from the Zig module:
const vt = @import("ghostty-vt");
const w = vt.unicode.codepointWidth(0x4E00); // 2
The function is total over its input: 0 for zero-width codepoints
(controls, combining marks, default-ignorables, surrogates), 2 for
wide codepoints (East Asian Wide/Fullwidth, regional indicators,
clamped at 2), and 1 for everything else, including invalid values
beyond U+10FFFF.
Perf: uses the LUT lookup we use for the main core terminal
Binary size: the width table was already linked into libghostty-vt
via the print path, so this adds only the exported wrapper.