Velocitty 1.3 - Input Redesign and Paint Skipping

Problem

Terminal emulation is trickier than it seems. Even if you parse every sequence and drop old text — which is a patch I tried before coming up with this redesign — you can still have sync issues because of sequences like CSI ? 7 h and CSI ? 7 l that enable and disable automatic line wrapping.

A terminal is not a bunch of HTML with bits of inline CSS. Simply discarding old output will eventually cause long-lasting synchronization issues. For example, if a 64 KiB image was preceded by CSI ? 1049 h (switching to the alternate screen), discarding that screen switch would cause Velocitty 1.2 to draw the image over the main screen instead.

In high-output workloads, much of the input is plain ASCII that scrolls off-screen or gets cleared before a frame is displayed.

That's what I wanted to address in 1.3: skip painting that can be proven obsolete, without skipping the terminal state attached to it.

Technical feedback

Special thanks to Mitchell Hashimoto for the technical feedback on Velocitty 1.2. He also posted a benchmark comparing its VT processing throughput with Ghostty. I've kept the original post and chart here for reference.

Mitchell Hashimoto's post on Velocitty 1.2.0
Mitchell Hashimoto's critique of Velocitty 1.2.0.
VT processing throughput, Velocitty 1.2.1 vs Ghostty, M4 Max
The benchmark attached to the post.

Redesign

A terminal can receive a lot of output in a very short time. Sometimes that output is immediately cleared or scrolled off-screen before it can even be displayed. Painting all of it is wasted work!

Velocitty now reads into a mirrored 1 MiB ring buffer. Before the VT parser sees the input, a pre-parser splits it into runs: text, ESC, C0, CSI, OSC and other control strings. Each run stores an offset and length pointing to its backing storage. It doesn't need to copy the whole input just to describe it.

Normally, the runs are consumed at the next frame boundary, or when input closes. This gives reads time to accumulate. Once the ring passes 75% occupancy, pressure consumption kicks in with skipText=true. That's when the isSpanObsolete lookahead is used. On smaller workloads it's off, so I'm not paying for the visibility proof when there isn't much to gain.

In both cases, the runs reach the VT parser in order and without unconsumed ring bytes being overwritten.

The 1 MiB input ring, categorized runs, temporary stream buffer, ordered VT consumption and separate image store
The new input feeder and pre-parser.

Large payloads

The ring buffer is now 1 MiB. A control string can be much larger than that, or arrive across many reads. For unfinished OSC, DCS, APC, PM and SOS strings, the pre-parser moves the bytes it already has into a separate, growable stream buffer. Subsequent reads can append straight to that buffer even if the ring is empty.

When the terminator sequence arrives, the pre-parser emits a run with flags.stream. This signals that the offset and length point to the stream storage instead of the ring buffer. Anything after the terminator goes back into the ring. The VT parser still receives everything in order, even though the bytes come from two different places.

An unfinished control string grows in stream storage and becomes an ordered run after its terminator
Example of a payload being streamed to the buffer.

This handles framing for things like Kitty APC graphics, sixel DCS bodies and OSC 52 clipboard data.

There are still limits here. Complete control strings initially take space in the ring, and a large string can cross the 75% threshold before streaming starts. So bulk payload traffic isn't fully separate from pressure accounting yet. CSI/SGR don't use this stream path, and incomplete CSI/ESC input has its own 4 KiB hold limit. None of that, by itself, proves that payload pressure explains a benchmark difference.

Image ownership

Note that the temporary buffer is not the image store. The image store owns its encoded payloads or decoded RGBA pixels, along with image IDs and placements. A sufficiently large completed Kitty APC upload can hand its allocation directly to the image store, avoiding another payload copy. Other paths copy or transfer their own storage.

The image cache is limited to 64 images and a separate 320 MiB logical quota, with unplaced entries evicted first. Each image reserves whichever is larger: its encoded payload size or decoded RGBA size. That way, lazy decoding doesn't suddenly change its quota cost. Decode scratch space and allocator overhead are extra. This quota is separate from the stream-buffer cap, and neither is a limit on the process's total RSS.

Skipping obsolete painting

This is an important distinction: I'm skipping painting, not terminal state. isSpanObsolete looks at later runs to prove that earlier ASCII/UTF-8 cell painting won't affect the final visible result. That text still advances the cursor, wraps lines and updates grapheme state. SGR, mode changes, queries, replies and graphics are still processed in order!

The proof uses qualifying screen clears, or enough line feeds scrolling the full region, to show that earlier painted cells are gone. It stops conservatively at things it can't reason about safely (for now) like cursor movement, rectangle copies that read existing cells, screen switches and restores. If this function can't prove a span is obsolete, the painting is not skipped.

The lookahead is lazy. Instead of scanning the runs every time, the pre-parser only does it when the VT first needs to know whether some text is obsolete. It scans backwards once, remembers the cutoff, and reuses that result for the rest of the batch.

Full-region line feeds prove earlier text obsolete while cursor, color and wrapping state remain ordered
Scrollback example.
A later screen clear makes earlier cell writes obsolete while preserving cursor and SGR state
Clear screen (CSI 2 J) example.

There's a lot of room for clever heuristics and optimizations here, but I kept things simple because it's more of a proof-of-concept.

Benchmarks

I also wanted a comparison that measures VT processing directly, without mixing in rendering and window presentation. This is a headless comparison with Ghostty 1.4.0, using the same generated terminal stream and grid dimensions. Velocitty's exact mode and its --drop text-skipping mode are shown separately, so the cost of the optimization isn't hidden inside the baseline.

Headless VT throughput: Velocitty exact and text skipping versus Ghostty 1.4.0 exact
velocitty-bench 1.3 in exact and --drop text-skipping modes versus ghostty-bench 1.4.0 exact, on an Intel i7-10610U. Median of five CPU-time runs on 2026-11-01, generated via scripts/bench_vs_ghostty.py. No rendering or window presentation.

The compatible +terminal-stream mode in velocitty-bench uses identical generated input, identical grid dimensions and CPU time (user + system). It doesn't require the two programs to take exactly the same wall-clock time. Using the same stream also avoids differences caused by applications choosing different output for TERM=xterm-256color and xterm-ghostty.

Early designs

I tried a few simpler ideas first. One was a "dead zone" where I ignored ASCII/UTF-8 before a certain point. It didn't help much in practice, but it led me toward this design: instead of using a cutoff, I need to prove the painting won't matter.

These are my original notebook sketches.

First notebook sketch of the Velocitty 1.3 input redesign
First sketch for the 1.3 redesign.
Second notebook sketch of the Velocitty 1.3 input redesign
Second sketch for the 1.3 redesign.

Changelog

Besides the ring and pre-parser redesign, there were a lot of smaller fixes and improvements. Here's the full list.

Performance and output handling

I got the style table idea from Ghostty.

Unicode and text

Graphics

Font loading

Correctness and input

Benchmarks and tests

Packaging and documentation