setCursorPos added origin-mode margins to requested row and column
values before clamping them to the scrolling region. A request near
maxInt(usize) overflowed during that addition and crashed instead of
landing on the region boundary.
Use saturating addition for the origin offsets. The existing clamp then
places oversized requests on the bottom-right margin without changing
normal cursor positioning.
Scrollback compression scheduling was only available to Zig callers that
used Terminal directly, leaving C embedders unable to drive the same idle
compression policy.
Define ABI-aware mode and result enums on Terminal and export activity
and compression operations through the C API. Keep scheduling
caller-owned, validate C inputs, and document the incremental contract
with a complete example.
Report unsupported reclamation consistently for full passes so callers
can disable compression on targets that cannot retain decommitted
mappings.
Compression previously stopped whenever the viewport left the active
area, leaving all scrollback resident while a user viewed history.
Traverse complete historical pages through a metadata-only iterator
which skips the contiguous visible range. Restart incremental traversal
after every viewport movement so pages become eligible once they leave
view, while visible pages remain resident for immediate redraw.
Add a PageList-only drain mode for tests and benchmarks, and update
scrollback documentation to describe the offscreen eligibility rule.
Compression scheduling previously postponed its idle timer after every
renderer wake. The inspector redraw loop wakes the renderer
continuously, so opening it could prevent pending scrollback compression
from ever starting.
Track compression-relevant PageList activity with a wrapping 48-bit
token and restart the timer only when the composite Terminal token
changes. This removes the separate dirty bit while reserving 16 bits for
future Terminal-owned compression triggers.
Expose target availability through the terminal package and leave
renderer compression state undefined on unsupported targets so its timer
is never initialized.
PageList previously kept only traversal position, so callers had no
central signal for deciding when an incremental compression pass should
run. Scheduling policy had to infer work from output and UI activity.
Track compression dirtiness alongside the PageList continuation state.
Growth preserves valid progress while marking work pending, and resize
or viewport transitions restart from the oldest page. A no-work
verification pass clears the state.
Expose Terminal helpers which report whether compression is required and
run compression against primary scrollback even while the alternate
screen is active. Unsupported retained-memory targets report no work.
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.
libghostty-vt already tracked OSC color state but ignored color queries in the standalone stream handler. This meant embedders that installed write_pty still received no response for OSC 4/10/11/12 or Kitty OSC 21 queries.
Resolve the current terminal colors through shared Terminal helpers and encode replies through the write_pty effect. Xterm queries use the fixed 16-bit rgb form, preserve the request terminator, and fall back from cursor to foreground when no cursor color is set. Kitty color queries now report supported terminal-backed keys and return empty values for unset dynamic colors.
Add RGB wire encoders and tests covering the stream handler and C API. The OSC parser now releases color operation request lists during reset, fixing an allocation leak exposed by multi-query OSC color tests.
This optimizes scrolling inside a scroll region (DECSTBM).
## The changes
1. **Stop creating scrollback for top-anchored regions on screens that don't
retain scrollback.** `index()` routed any full-width region with `top == 0`
through `cursorScrollAbove()`, which pushes the scrolled-out row into
scrollback. Every scroll paid `PageList.grow()` plus amortized page pruning,
which includes a 512 KB `memset` each time a page is recycled. These now
use the in-place region scroll instead. CSI S gets the same routing fix.
**Result: 1.05x-1.49x on the bottom-anchored region workloads, 1.25x on
alt-screen full-screen scrolling.**
2. **Add a specialized `Screen.cursorScrollRegionUp()` for the region scroll
hot path.** The previous fast path (`PageList.eraseRowBounded`) paid
per-scroll bookkeeping that exceeded the actual row work.
The new function is built around the invariant that the cursor sits on the
bottom row of a full-width region.
**Result: 1.23x-1.24x on the top-anchored region workloads.**
## Benchmarks
| workload | region (80 rows) | before | after | change |
|---|---|---|---|---|
| scrolling (control) | primary screen, no region | 237 ms | 235 ms | 1.0x |
| scrolling_bottom_region | alt, rows 1-79 | 243 ms | 231 ms | 1.05x |
| scrolling_bottom_small_region | alt, rows 1-40 | 311 ms | 208 ms | 1.49x |
| scrolling_top_region | alt, rows 2-80 | 283 ms | 229 ms | 1.23x |
| scrolling_top_small_region | alt, rows 40-80 | 258 ms | 208 ms | 1.24x |
| alt screen full-screen scrolling | alt, no region | 288 ms | 230 ms | 1.25x |
## LLM Notes
Assisted by Fable 5: it diagnosed the vtebench gap, wrote the benchmark
harness payloads, profiled, and proposed hot paths. I manually wrote the
hot path replacements and had it judge my work.
This optimizes `RenderState.update`, the per-frame call that snapshots
terminal state for the renderer and is the main reason the renderer
thread holds the terminal lock.
Lock hold time is reduced ~2.7x to ~11x depending on the frame.
## The changes
1. iterate page chunks instead of rows in `update`
2. classify cells with masked vector compares.
3. split the update into `beginUpdate`/`endUpdate` phases. There's a
lot to be gained by accumulating data with the lock held and then
processing it out of the lock.
4. generalize the masked-compare scans into `page.Mask`. This is just
a really common pattern we're doing now and it yields a ton of great
value. Its error prone so lets make it a tested helper.
## Benchmarks
Measured with the new `ghostty-bench +screen-clone` modes (`render`,
`render-locked`, `render-clean`, `render-partial`), 120x80 terminal, M4
Max, macOS 26, ReleaseFast, hyperfine means of 10+ runs, per-update
times derived from fixed-count update loops with process startup
subtracted. "Lock held" is the time the terminal lock must be held per
update; "before" held the lock for the entire update.
| scenario | before (lock held) | after (lock held) | after (total) | lock change |
|----------|--------------------|-------------------|---------------|-------------|
| clean frame (nothing dirty) | 202 ns | 19 ns | 19 ns | 10.9x |
| partial frame (1 dirty row) | 290 ns | 54 ns | 54 ns | 5.4x |
| full rebuild, lightly styled | 6.9 µs | 2.5 µs | 3.0 µs | 2.7x |
| full rebuild, fully styled | 9.3 µs | 2.4 µs | 8.0 µs | 3.8x |
| full rebuild, fully styled, 250x150 | 49.9 µs | 9.4 µs | 31.6 µs | 5.3x |
| full rebuild, plain text | 1.9 µs | 1.9 µs | 1.9 µs | 1.0x (memcpy floor) |
The clean and partial cases are the steady-state frame costs (cursor
blink, mouse movement, typing). The full-rebuild cases are the contended
ones: colored scrolling output (build logs, htop, vim) moves the
viewport pin every frame, forcing a full rebuild exactly when the IO
thread is busiest, so that row of the table is where lock contention
actually hurts. Plain text was already at the memcpy floor and is
unchanged.
## LLM Notes
This work was driven by Fable 5: benchmarks, optimizations, the property
test, and the measurements above. I reviewed every line, simplified the
design in a few places (API naming, the Mask helper shape), and re-ran
the verifications myself.
Profiling terminal-stream on a 2.6 GB recording of real terminal
sessions showed 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% |
#13209
After #13209 the IO pipeline delivers the parse thread's full
measured capacity, so IO throughput is now bound by VT processing.
Profiling `terminal-stream` on plain text showed ~85% of wall time
inside Terminal.print: every printable codepoint paid the full
per-character cost (right margin computation, grapheme clustering
checks, width lookup, wrap/insert mode checks, charset mapping,
per-cell style bookkeeping, dirty marking, cursor advance) even
though for typical bulk output every one of those answers is the
same for thousands of consecutive characters.
This adds a new print_slice stream action carrying a run of
printable codepoints, emitted whenever the SIMD ground-state path
decodes multiple codepoints at once, plus Terminal.printSlice which
processes such runs in batch. Since action dispatch is comptime,
delivering a slice through the existing vt handler interface has
the same codegen as a dedicated entry point; handlers that don't
care about batching can simply loop and treat each codepoint as a
print action.
printSlice hoists all run-invariant checks (status display, insert
and wraparound modes, charset state, hyperlink state) out of the
loop and then fills cells row by row. A single masked u64 compare
classifies each destination cell as "simple" (plain codepoint cell,
narrow, no hyperlink, style already matching the cursor); runs of
simple cells are written with a branch-free store loop, style-only
mismatches are handled inline with the same ref-counting printCell
does, and anything needing real cleanup (wide spacers, grapheme
data, hyperlinks) exits the fast path with the cursor positioned on
the offending cell so print() handles that one codepoint with full
generality. Dirty marking, previous_char, and cursor advancement
happen once per row instead of once per character.
The fast path handles both narrow and wide codepoints (CJK/emoji are
written as wide+spacer_tail pair fills, including spacer-head
handling at the right edge) and stays exact under grapheme
clustering (mode 2027): a codepoint only joins a run if it is width
1 or 2 and is a grapheme break from the previously written
codepoint, so print() would never have attached it to the previous
cell. The first codepoint of a batch defers to print() whenever the
previous cell could carry cluster state we can't cheaply reason
about (including a pending wrap, where print attaches to the
pending cell instead of wrapping).
Correctness is verified by a new differential fuzz test that runs
the same operations through per-codepoint print and randomly
chunked printSlice, comparing full screen dumps, cursor state, and
page integrity (style refcounts, grapheme maps) after every
operation, across wraps, margins, mode toggles, hyperlinks,
charsets, and wide/combining/ZWJ/RI/jamo codepoints.
Throughput measured with ghostty-bench terminal-stream (full
terminal handler, 100 MB deterministic corpora, 120x80, M4 Max,
ReleaseFast, hyperfine means of 10 runs; ~15ms process startup
included in all numbers):
| stream | before | after | change |
|---------------------------|--------|--------|--------|
| ascii (no newlines) | 784 ms | 138 ms | 5.7x |
| ascii lines | 833 ms | 198 ms | 4.2x |
| unicode mixed-script | 779 ms | 320 ms | 2.4x |
| CJK (all wide) | 424 ms | 126 ms | 3.4x |
| unicode, mode 2027 on | 807 ms | 367 ms | 2.2x |
| CJK, mode 2027 on | 495 ms | 198 ms | 2.5x |
Embedders that render 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.
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 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.
This fixes a bug where the variation selectors (VS15 & VS16) were
checked against the first codepoint in a cell instead of the previous
codepoint in the cell's grapheme cluster, causing them to be dropped if
the first codepoint was not a valid base.
This PR fixes an issue where a zero-width combining mark could attach to
the wrong cell when the preceding character was written in the final
column and the cursor had a pending wrap.
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.
Move kitty_image_storage_limit and kitty_image_loading_limits into
Terminal.Options so callers can set them at construction time
rather than calling setter functions after init. The values flow
through to Screen.Options during ScreenSet initialization. Termio
now passes both at construction, keeping the setter functions for
the updateConfig path.
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.
Previously every file in the terminal package independently imported
build_options and ../lib/main.zig, then computed the same
lib_target constant. This was repetitive and meant each file needed
both imports just to get the target.
Introduce src/terminal/lib.zig which computes the target once and
re-exports the commonly used lib types (Enum, TaggedUnion, Struct,
String, checkGhosttyHEnum, structSizedFieldFits). All terminal
package files now import lib.zig and use lib.target instead of the
local lib_target constant, removing the per-file boilerplate.
Add title and pwd as both gettable data keys
(GHOSTTY_TERMINAL_DATA_TITLE/PWD) and settable options
(GHOSTTY_TERMINAL_OPT_TITLE/PWD) in the C terminal API. Getting
returns a borrowed GhosttyString; setting copies the data into the
terminal via setTitle/setPwd.
The underlying Terminal.setTitle/setPwd now append a null sentinel so
that getTitle/getPwd can return sentinel-terminated slices ([:0]const
u8), which is useful for downstream consumers that need C strings.
Change ghostty_terminal_set to return GhosttyResult instead of void
so that the new title/pwd options can report allocation failures.
Existing option-setting calls cannot fail so the return value is
backwards-compatible for callers that discard it.
Add a title field to Terminal, mirroring the existing pwd field.
The title is set via setTitle/getTitle and tracks the most recent
value written by OSC 0/2 sequences. The stream handler now persists
the title in terminal state in addition to forwarding it to the
surface. The field is cleared on full reset.
Rename stream_readonly.zig to stream_terminal.zig and its exported
types from ReadonlyStream/ReadonlyHandler to TerminalStream. The
"readonly" name is now wrong since the handler now supports
settable effects callbacks. The new name better reflects that this
is a stream handler for updating terminal state.
Previously, every call to vt_write created a fresh ReadonlyStream with
new Parser and UTF8Decoder state. This meant escape sequences split
across write boundaries (e.g. ESC in one write, [27m in the next)
would lose parser state, causing the second write to start in ground
state and print the CSI parameters as literal text.
The C API now stores a persistent ReadonlyStream in the TerminalWrapper
struct, which is created when the Terminal is initialized. The vt_write
function feeds bytes through this stored stream, allowing it to maintain
parser state across calls. This change ensures that escape sequences
split across write boundaries are correctly parsed and rendered.
Move MouseEvent and MouseFormat out of Terminal.zig and MouseShape out
of mouse_shape.zig into a new mouse.zig file. The types are named
without the Mouse prefix inside the module (Event, Format, Shape) and
re-exported with the prefix from terminal/main.zig for external use.
Update all call sites (mouse_encode.zig, surface_mouse.zig, stream.zig)
to import through terminal/main.zig or directly from mouse.zig. Remove
the now-unused mouse_shape.zig.
Move mouse event encoding logic from Surface.zig into a new
input/mouse_encode.zig file.
The new file encapsulates event filtering (shouldReport),
button code computation, viewport bounds checking, motion
deduplication, and all five wire formats (X10, UTF-8, SGR,
urxvt, SGR-pixels). This makes the encoding independently
testable and adds unit tests covering each format and edge
case.
Additionally, Surface `mouseReport` can no longer fail, since the only
failure mode is no buffer space which should be impossible. Updated
the signature to remove the error set.
When a grapheme expands to width 2 at the screen edge, this path can write
spacer_head before printWrap() sets row.wrap. With an active hyperlink,
printCell triggers hyperlink bookkeeping and page integrity checks in that
intermediate state, causing UnwrappedSpacerHead.
Mark row.wrap before writing spacer_head in this grapheme-wrap path to keep
the intermediate state valid.
When insertBlanks clears the entire region from cursor to the right
margin (scroll_amount == 0), a wide character whose head is at the right
margin gets cleared but its spacer_tail just beyond the margin is left
behind, causing a "spacer tail not following wide" page integrity
violation.
Move the right-margin wide-char cleanup from inside the scroll_amount >
0 block to before it, so it runs unconditionally — matching the
rowWillBeShifted pattern of cleaning up boundary-straddling wide chars
up front.
Found via AFL++ fuzzing. #11109
When deleteLines or insertLines count >= scroll region height, all rows
go through the clear-only path (no shifting). This path did not call
rowWillBeShifted, leaving orphaned spacer_tail cells when wide characters
straddled the right margin boundary, causing a "spacer tail not following
wide" page integrity violation.
Add rowWillBeShifted before clearCells in the else branch of both
functions.
Found via AFL++ fuzzing. #11109
printCell, when overwriting a wide cell with a narrow cell at x<=1 and
y>0, unconditionally sets the last cell of the previous row to .narrow.
This is intended to clear a spacer_head left by a wrapped wide char, but
the cell could be a spacer_tail if a wide char fit entirely on the
previous row. Setting a spacer_tail to .narrow orphans the preceding
.wide cell, which later causes an integrity violation in insertBlanks
(assert that the cell after a .wide is .spacer_tail).
Fix by guarding the assignment so it only fires when the previous row's
last cell is actually a .spacer_head. The same fix is applied in both
the .wide and .spacer_tail branches of printCell.
Found by AFL++ stream fuzzer.
insertBlanks checks whether the last source cell being shifted is wide
and clears it to avoid splitting, but it did not check the destination
cells at the right edge of the scroll region. When a wide character
straddles the right scroll margin (head at the margin, spacer_tail just
beyond it), the swap loop displaced the wide head without clearing the
orphaned spacer_tail, causing a page integrity violation
(InvalidSpacerTailLocation).
Fix by checking the cell at the right margin (last destination cell)
before the swap loop and clearing it along with its spacer_tail when it
is wide.
Found by AFL++ stream fuzzer. #11109
Printing a wide character at the right edge of the screen with an active
hyperlink triggered a page integrity violation (UnwrappedSpacerHead).
printCell wrote the spacer_head to the cell and then called
cursorSetHyperlink, whose internal integrity check observed the
spacer_head before printWrap had a chance to set the row wrap flag.
Fix by setting row.wrap = true before calling printCell for the
spacer_head case, so all integrity checks see a consistent state.
printWrap sets wrap again afterward, which is harmless. Found by AFL++
stream fuzzer.
CSI @ (ICH) with an explicit parameter of 0 should be clamped to 1,
matching xterm behavior. Previously, a zero count reached
Terminal.insertBlanks which called clearCells with an empty slice,
triggering an out-of-bounds panic.
Fix the stream dispatch to clamp 0 to 1 via @max, and add a defensive
guard in insertBlanks for count == 0. Found by AFL++ stream fuzzer.
This works around Fish (at least v4.2) having a non-compliant OSC133
implementation paired with not having the hooks to fix this via shell
integration. We have to instead resort to heuristics in the terminal
emulator. Womp, womp.
The issue is that Fish does not emit OSC133 secondary prompt (`k=s`)
markers at the beginning of continuation lines. And, since Fish doesn't
provide a PS2-equivalent, we can't do this via shell integration.
We fix this by assuming on newline (`\n`) that a cursor that is already
painting prompt cells is continuing a prior prompt line, and
pre-emptively mark it as a prompt line. But this has two further issues
we have to work around:
1. Newline/index (`\n`) is one of the _hottest path_ functions in
terminal emulation. It sucks to add any new conditional logic here.
We do our best to gate this on unlikely conditions that the branch
predictor can easily optimize away.
2. Fish also emits these for auto-complete hints that may be deleted
later. So, we also have to handle the scenario where a prompt is
continued, then replaced by command output, and fix up the prompt
continuation flag to go back to output mode.
Point 2 is ALMOST automatically handled, because Fish does emit a `CSI J`
(erase display below) to erase the auto-complete hint. This resets all
our rows back to output rows. **Unfortunately**, Fish emits `\n` before
triggering the preexec hooks which set OSC133C. So we get the newline
logic FIRST (sets the prompt line), THEN sets the output cursor. If they
switched ordering here everything would just work (with the one
heuristic). But now, we need two!
To address this, I put some extra heuristic logic in the OSC133C
(output starting) handler: if our row is marked as a prompt AND our
cursor is at x=0, we assume that the prompt continuation was deleted
and we unmark it.
I put the heuristic logic dependent on OSC133C because that's way colder
of a path than putting something in `printCell` (which is the actual
hottest path in Ghostty).
We could get more rigorous here by also checking if every cell is empty
but that doesn't seem to be necessary at this time for any Fish version
I've tested. I hope thats correct.
I'd really love for Fish to improve their OSC133 implementation to
conform more closely to the terminal-wg spec, but we're going to need
these workarounds indefinitely to handle older Fish versions anyway.