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.
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.
(#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.
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.
The generation counter added in bdc0b6c19 is a process-global
std.atomic.Value(u64). The Zig compiler rejects 64-bit atomic
operations on targets whose largest supported atomic size is 32 bits
(e.g. arm-linux-androideabi), which broke the libghostty-vt Android
build. This slipped past other CI targets because they're either
64-bit or compile kitty graphics out entirely (wasm32-freestanding).
The counter backing nextGeneration is now comptime-selected: 64-bit
targets keep the lock-free atomic counter, while 32-bit targets fall
back to a mutex-protected u64. This preserves the process-wide
uniqueness and monotonicity guarantees of generation stamps
everywhere. The mutex cost is irrelevant since this is a cold path,
only invoked on content mutations.
Add a generation counter to the kitty graphics image storage. Every
content mutation (image transmit/replace, placement add, delete)
assigns the storage a fresh stamp, and every image is stamped when
it is added or replaced.
This solves two problems:
First, a retransmission of the same image ID with identical dimensions
was previously undetectable by anything comparing width, height, format,
and data length; the per-image stamp changes on every add/replace, so caches
keyed on it always see the change. Second, the dirty flag was the only
storage-wide signal, and it is also set by scrolling and resizing, which move
placements without changing contents. The generation is only bumped by content
mutations, so an unchanged value means the placement set and all
image data are identical and consumers can skip re-reading them,
recomputing only placement geometry.
The generation replaces Image.transmit_time entirely: newest-image
lookup by number, eviction ordering, and the renderer's texture
staleness checks all key on it now. A monotonic counter strictly
orders transmissions where Instant-based times could collide within
clock resolution (the renderer previously assumed equal timestamps
meant identical images), and this removes a syscall and an error
path per transmission.
Delete commands now only mark a mutation (dirty flag and
generation) when they actually remove something. A delete-all runs
on every screen clear, so previously every ESC [ 2 J dirtied the
image state even with no images stored. Eviction via setLimit also
now marks the state dirty, which it previously did not.
Both generations are exposed through libghostty-vt as
GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION and
GHOSTTY_KITTY_IMAGE_DATA_GENERATION (uint64_t), and the headers now
document that stored image data is always post-inflate/post-decode:
COMPRESSION always reports NONE, FORMAT is never PNG, and DATA_PTR
is raw pixels ready for GPU upload.
uint64_t gen = 0;
ghostty_kitty_graphics_get(
graphics, GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION, &gen);
if (gen == last_gen) return; // nothing changed, skip re-reads
Selection gestures are already part of the libghostty-vt C API, but the
native Zig module did not re-export the underlying terminal type. Zig
consumers that implement mouse selection had to reach into terminal
internals instead of using @import("ghostty-vt").
Re-export SelectionGesture from lib_vt alongside the other terminal
selection and screen types.
The GRAPHEMES_UTF8 row-cells getter inferred its required byte
accumulator from utf8CodepointSequenceLength, which stores the
value in u3. Multi-scalar clusters longer than seven UTF-8 bytes
could overflow that accumulator before the capacity check, causing
wrong probe sizes and allowing optimized builds to write past a
caller-provided buffer.
Use usize for the required byte count so probing and capacity
checks match the later encode loop. Extend the render C API test
to cover the short combining cluster, an eight-byte flag cluster,
a longer family emoji, exact-size success, and the
cap == needed - 1 no-write boundary.
I've implemented a GTK toggle (gtk-horizontal-tab-scroll) for the
2-finger tab swiping introduced in #10575.
This resolves the issue presented in #11566. Simply put, this allows
users to decide whether or not they want to use horizontal tab scrolling
or just have the events passed through. Passing through the horizontal
scroll events allows programs like Neovim to use them for horizontal
scrolling.
This PR was largely generated by Claude Code and fully reviewed/refined
by me.
Trims over-commented fields and enum variants to match the repo
baseline (kitty_clipboard_protocol style). Adds 11 tests covering
metadata/payload parsing, all EventType values, integer keys,
negative sentinels, case-sensitive key matching, and BEL terminator
recording. Removes the development walkthrough document.
Adds an OSC 72 parser following the kitty drag and drop protocol
specification. Parses metadata and payload into a Command.kitty_dnd_protocol
variant. Reassembly of chunked transfers and any action handling are
intentionally out of scope here; stream.zig logs the command as
unimplemented for now.
Includes a walkthrough document covering the design and each touched file.
## Context
Implements the OSC 133 `click_events=2` such that click events on the
terminal are sent to the shell where the y coordinate is sent relative
to the prompt pin.
See
https://sw.kovidgoyal.net/kitty/shell-integration/#click_events=1|2:~:text=click%5Fevents,xterm
further detailed explanation.
This should close https://github.com/ghostty-org/ghostty/issues/10865
### Testing
I did some basic manual testing here via the following:
```
ghostty main
❯ printf '\033]133;A;click_events=1\007' && cat
^[[<0;49;4M^[[<0;48;8M^[[<0;47;12M^C
ghostty main 5s
❯ printf '\033]133;A;click_events=2\007' && cat
^[[<0;50;1M^[[<0;49;4M^[[<0;48;9M^[[<0;48;14M^C
```
#### Notes
This is my first Ghostty PR. All code here is hand-rolled, AI was used
to do "smart" code searches/point me in the right direction.
I'm actively trying to learn Zig, Ghostty, and get more involved with
the community here so I'm avoiding obtuse usage of AI where possible.
Any feedback or tips are more than welcome! Thank you! 🙏
Previously the libghostty-vt stream handler dropped .report_pwd as a
no-op, so embedders never saw shell-reported cwd changes and the
terminal's pwd field was never populated from escape sequences.
Wire the action to setPwd and expose a pwd_changed callback analogous
to title_changed via GHOSTTY_TERMINAL_OPT_PWD_CHANGED. The payload is
passed through unparsed; embedders read it with ghostty_terminal_get
and decode any URI scheme themselves.
## Summary
- Adds a "Close Split" option to the right-click context menu in the
split submenu
- Allows users to close the focused split pane directly from the context
menu
Reference discussion:
https://github.com/ghostty-org/ghostty/discussions/10982
This hooks up the glyph protocol glossary to the terminal state. This
effectively makes us handle the APC protocol for it both in Ghostty GUI
and libghostty, although we didn't implement the renderer yet.
The Zig/C libghostty API also has a way to disable the protocol but it is
enabled by default. The memory usage is bound by the specification.
For dirty tracking for the renderer, we're going with the simple route that
any glyph change marks a coarse grained dirty flag and we'll [in the future]
rebuild the entire state in the renderer. I think this will be fine for
realistic workloads, but we can reassess in the future when we have
real workloads.
selectPrev's wrap (active_len + history_len - 1) would underflow if a
selection were live while both result lists are empty. Add a test that
exercises the invariant making that unreachable: overwriting the only match
forces a reload that empties both lists and drops the selection, so the next
select() hits the no-matches guard instead of the wrap arithmetic.
Found this issue when testing some search features; follow up for
#12907.
You can either reproduce using the PoC below with `libghostty-vt` or run
Ghostty debug on macOS and follow these steps:
1. Open Ghostty and start search `0`.
2. Press `cmd+=` to increase font size.
3. You should see a panic crash.
### AI Disclosure
As the commit suggests, Claude implemented the fix, the unit test, and
PoC file.
I reviewed it(seems reasonable to me, but I’m not a Zig professional)
and tested it myself.
```zig
// PoC: resize panic when shrinking both axes with the cursor near the top
// of a fully-populated screen.
//
// Build (with libghostty-vt headers + dylib on the standard search paths):
// zig run poc.zig -lghostty-vt
//
// Or point at a local build:
// zig run poc.zig -I <prefix>/include -L <prefix>/lib -lghostty-vt
//
// At runtime the dylib must be discoverable (DYLD_LIBRARY_PATH on macOS,
// LD_LIBRARY_PATH on Linux, or an rpath baked in at link time).
//
// Without the fix, this aborts with
// reached unreachable code (assert in PageList.Pin.pageIterator)
// at _terminal.PageList.resizeCols on a debug/safe build. On release it
// silently iterates an empty (reversed) range.
const std = @import("std");
const c = @cImport({
@cInclude("ghostty/vt.h");
});
pub fn main() !void {
var term: c.GhosttyTerminal = null;
const opts: c.GhosttyTerminalOptions = .{
.cols = 80,
.rows = 24,
.max_scrollback = 1000,
};
if (c.ghostty_terminal_new(null, &term, opts) != c.GHOSTTY_SUCCESS) {
return error.InitFailed;
}
defer c.ghostty_terminal_free(term);
// Fill every one of the 24 active rows with non-blank content. This is
// what makes the bug reachable: when rows shrink, resizeWithoutReflow
// can only trim *blank* trailing rows, so non-blank rows are instead
// pushed up into scrollback and the active-area top moves down.
{
var buf: [256]u8 = undefined;
var i: usize = 0;
while (i < 24) : (i += 1) {
// "X" on each row; CR+LF between rows but not after the last so
// we don't scroll the top row away.
const line = if (i + 1 < 24)
std.fmt.bufPrint(&buf, "X\r\n", .{}) catch unreachable
else
std.fmt.bufPrint(&buf, "X", .{}) catch unreachable;
c.ghostty_terminal_vt_write(term, line.ptr, line.len);
}
}
// CSI 1;1H -> park the cursor on the TOP row (1-based). The active area is
// anchored to the bottom, so once we shrink rows this row falls above the
// new active-area top, i.e. into scrollback.
const move = "\x1b[1;1H";
c.ghostty_terminal_vt_write(term, move.ptr, move.len);
// Shrink both axes. Columns must shrink to take resize()'s .lt branch,
// which runs the row shrink first and then resizeCols with the original
// (now out-of-active-area) cursor pin. Panics in
// _terminal.PageList.resizeCols.
_ = c.ghostty_terminal_resize(term, 79, 20, 8, 16);
std.debug.print("survived resize (fix is present)\n", .{});
}
```
This adds the glossary and request handler logic to the glyph protocol
package.
We now have a fully spec compliant business-logic part of the glyph
protocol.
**This doesn't yet hook it up to terminal state.** So it isn't impacting
any real-world usage yet.
Code was hand-written, tests were AI-assisted and human reviewed.
Extend glyph render constraints with cell-span sizing modes for height,
width, contain, cover bounds, and stretch bounds. These preserve the
existing face-targeted behavior for platform fonts, emoji, and Nerd Font
rules while giving registered glyphs a target based on terminal cell
spans.
Map Glyph Protocol registration options to the new constraint modes so
sizing follows the spec formulas based on authored advance width and line
height. Baseline alignment now places design-space y=0 on the terminal
text baseline instead of approximating it as start alignment.
Document the placement formulas in the local protocol summary and add
focused tests for constraint mapping, cell-span padding, line-height and
advance scaling, contain versus cover behavior, stretch, and baseline
placement.
Register parsing now validates the full register request shape before
constructing the parsed command. Inputs that only contain the verb
separator, such as `r`, `r;cp=e0a0`, or `r;foo`, now fail with
InvalidFormat instead of reaching Register invariants guarded by asserts.
Valid empty-payload requests still parse when they include the payload
separator, allowing execution to report malformed_payload through the
normal protocol response path.
Glyph clear execution previously treated an unparsable cp option the same
as an omitted cp option. That made inputs such as c;cp=zz behave like a
bare clear request and remove every glossary registration.
Track clear option presence separately from successful decoding. A
present but malformed cp now returns a malformed_payload clear failure
without mutating the glossary, while an omitted cp still clears all
registrations.
In the column-shrink (.lt) branch of PageList.resize, resizeWithoutReflow
lowers self.rows before resizeCols runs. Because the active area is anchored
to the bottom, shrinking rows moves the active-area top down; a cursor near
the top of the old active area then ends up above the new active area (in
scrollback).
resizeCols counts wrap continuations from the cursor pin up to the active-area
top via a .left_up rowIterator. When the cursor pin is above the limit, the
range is reversed and the iterator's order assertion fires (SIGABRT in debug;
silently iterates empty in release).
Count zero wraps when the cursor pin is above the active area, mirroring the
post-reflow preserved-cursor block which already no-ops for a non-active
cursor. Add a regression test.