Page maps handled removal with tombstones, which a fixed-capacity map
can never outgrow. Keeping probe lengths bounded required an insertion
headroom counter, an allocation-free rehash, and five separate recovery
paths with subtle invariants: removal created a tombstone without
restoring headroom, so the counter could reach zero while the map was
half empty and every insertion path had to be prepared to rebuild.
Replace tombstones with backward-shift deletion (Knuth vol. 3, 6.4
algorithm R). Removal restores the table to the state it would be in
had the key never been inserted, so probe chains stay canonical at all
times and fragmentation cannot accumulate by construction. This deletes
the headroom counter, the in-place rehash, and every recovery path.
Insertion no longer invalidates pointers; removal may now move other
entries instead, and no caller holds entry pointers across removals.
Removal costs a cluster scan instead of a byte write, paying
incrementally what tombstones deferred to later probes and periodic
rebuilds. Free slots remain all-zero bytes so probe loops keep fusing
the state and fingerprint checks into single-byte compares. A
randomized oracle test against the stdlib map and a canonical-placement
invariant check cover the new removal path, including full tables where
no free slot terminates the shift.
ReleaseFast benchmarks against the prior map commits:
| workload | delta |
|---|---:|
| map churn, 50% load | 1.23x faster |
| map churn, 75% load | 1.15x faster |
| map churn, max load | 1.07x slower |
| map lookup | neutral |
| clear and redraw stream | 1.32x faster |
| full OSC 8 stream | 1.06x faster |
| linked-line movement | neutral |
#13292
Page maps previously allowed a 100% load factor. Once live entries
and tombstones filled every slot, a missing-key lookup or insertion
could scan the entire map.
Reserve 20% of each offset hash map as insertion headroom and track
that budget separately from the live count. Port the allocation-free
in-place rehash from Zig HashMapUnmanaged so canonical insertion can
rebuild fragmented probes and restore tombstone-exhausted headroom
without growing the page. Assumed-capacity insertion now applies the
same guard, preventing the headroom counter from wrapping.
Pass the exact requested hyperlink count into map layout before
load-factor scaling, avoiding a redundant power-of-two rounding step.
Screen-level recovery now grows only when live hyperlinks actually fill
usable capacity.
ReleaseFast terminal benchmarks compare main with the combined map
changes:
| workload | before | after | speedup |
|---|---:|---:|---:|
| map churn | 1.253 s | 12.5 ms | 100x |
| full OSC 8 stream | 3.576 s | 75.6 ms | 47x |
| clear and redraw | 299.1 ms | 118.3 ms | 2.5x |
Co-authored-by: Tim Culverhouse <tim@timculverhouse.com>
Screen scroll fast paths and Terminal line insertion/deletion move Row
values directly instead of using PageList erasure helpers. Their node
generations therefore stayed valid after cached row coordinates changed.
Expose the PageList layout invalidator to sibling terminal modules and
renew each affected existing page once before full-row rotations or
swaps. Keep partial-width cell moves on the content-only path and cover
same-page, cross-page, and fresh-tail behavior.
startHyperlink accepts borrowed URI and ID slices. When those slices came from the current cursor hyperlink, startHyperlinkOnce ended and freed that hyperlink before duplicating the replacement, then dereferenced the released URI and segfaulted.
Duplicate the new hyperlink before ending the prior one. Aliased inputs remain valid through the copy, and allocation failure leaves the existing cursor hyperlink intact.
Screen.select accepted an already tracked selection by value. Passing the screen's current selection back into the setter caused the old selection cleanup to free the same pin pair that the replacement retained, so the next selection operation dereferenced stale pool entries and panicked.
When replacing tracked state, release only old pins that are not also owned by the replacement. Exact and partial aliases now retain their shared pins while ordinary replacements still reclaim both old entries.
Implicit OSC 8 hyperlinks incremented a u32 identifier with checked arithmetic even though the cursor contract allows the sequence to wrap. Reaching the maximum identifier therefore caused a runtime safety panic before the link could be installed.
Use wrapping arithmetic for both the successful increment and the error rollback. The identifier now returns to zero at the boundary, while failed allocation attempts still restore the original value.
Screen.clearRows sliced backing cells using the desired PageList width.
During incomplete reflow, clearing a narrower stored page extended the
slice past its row and tripped clearCells runtime validation before any
cells were cleared.
Obtain cells from the owning page and use its stored width for whole-row
managed-memory bookkeeping. Mixed-width history rows now clear safely.
Screen.clone clipped selections with the desired screen width or copied
rectangle columns unchanged. PageList.clone preserves stored page widths,
so a clipped boundary on a narrower page produced an invalid tracked pin
and panicked during runtime validation.
Build fallback pins from the first or last cloned node and clamp rectangle
columns to that node. Clipped selections now remain valid during reflow.
cursorCellEndOfPrev moved its pin to the previous row but then set the
column from the desired screen width. If incomplete reflow left that
previous page narrower, resolving the cell used an out-of-bounds column
and panicked in runtime safety builds.
Set the column from the page reached by the cursor pin so the returned
cell is always the actual final cell of that row.
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.
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.
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 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.
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).
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.
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 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.
This PR fixes an issue where reflowing could leave the active cursor
attached to a clipped trailing blank cell instead of following the
current write position.
Maybe related to #12298?
When Screen resize forwards the active cursor into PageList reflow, a
history-pinned viewport can be remapped into the active area before the
preserved-cursor grow step finishes. The old code kept treating that
viewport as a history pin during the intermediate grow calls, which left
too few rows beneath the pin and tripped the viewport integrity checks.
Fix this by normalizing the viewport back to active as soon as reflow
moves the pinned row into the active area. Add a Screen-level regression
test that exercises the full resize path with bounded scrollback and
wrapped rows, and document the setup so the unwrap and viewport
transition are clear.
The default kitty image storage limit was 320 MB for all build
artifacts. For libghostty, this is overly generous since it is an
embedded library where conservative memory usage is preferred.
Lower the default to 10 MB when building as the lib artifact while
keeping the 320 MB default for the full Ghostty application.
Add a Limits type to LoadingImage that controls which transmission
mediums (file, temporary_file, shared_memory) are allowed when
loading images. This defaults to "direct" (most restrictive) on
ImageStorage and is set to "all" by Termio, allowing apprt
embedders like libghostty to restrict medium types for resource or
security reasons.
The limits are stored on ImageStorage, plumbed through
Screen.Options for screen initialization and inheritance, and
enforced in graphics_exec during both query and transmit. Two new
Terminal methods (setKittyGraphicsSizeLimit, setKittyGraphicsLoadingLimits)
centralize updating all screens, replacing the manual iteration
previously done in Termio.
Fixes#11957
erasePage now updates page_serial_min when the first page is erased,
and asserts that only front or back pages are erased since
page_serial_min cannot represent serial gaps from middle erasure.
To enforce this invariant at the API level, PageList.eraseRows is
now private. Two public wrappers replace it: eraseHistory always
starts from the beginning of history, and eraseActive takes a y
coordinate (with bounds assertion) and always starts from the top
of the active area. This makes middle-page erasure impossible by
construction.
Nu properly marks input areas with OSC 133 B, but if it spans multiple
lines it doesn't mark the continuation lines with the k=s sequence.
Our prompt redraw logic before only cleared explicitly designated prompt
lines. But if the input line is multi-line and the continuation lines
are not marked, those lines would not be cleared, leading to visual
issues on resize.
To workaround this, we assume that if the current cursor semantic
content is anything other than "command output" (default), then we're
probably at a prompt line and should clear from there all the way up.
- Change all codepoint types from u32 to u21 to align with Zig stdlib
- Update ArrayList to use Zig 0.15 unmanaged pattern (.empty)
- Remove unnecessary @intCast when encoding UTF-8
- Fix formatEntry to use stack-allocated buffer
Refactor the selection-word-chars implementation to parse UTF-8 boundary
characters once during config initialization instead of on every selection
operation.
Changes:
- Add SelectionWordChars type that stores pre-parsed []const u32 codepoints
- Parse UTF-8 to codepoints in parseCLI() during config load
- Remove UTF-8 parsing logic from selectWord() hot path (27 lines removed)
- Remove arbitrary 64-character buffer limit
- Update selectWord() and selectWordBetween() to accept []const u32
- Update DerivedConfig to store codepoints directly
- Update all tests to use codepoint arrays
Benefits:
- No runtime UTF-8 parsing overhead on every selection
- No arbitrary character limit (uses allocator instead)
- Cleaner separation of concerns (config handles parsing, selection uses data)
- Better performance in selection hot path