Skip to content

Sync with upstream ghostty-org/ghostty (216 commits) + test coverage for fork features - #5

Open
vinise wants to merge 217 commits into
tomreinert:developmentfrom
vinise:sync-upstream
Open

vinise wants to merge 217 commits into
tomreinert:developmentfrom
vinise:sync-upstream

Conversation

@vinise

@vinise vinise commented Jul 31, 2026

Copy link
Copy Markdown

Sync with upstream ghostty-org/ghostty (216 commits) + test coverage for fork features

Summary

This PR brings the fork back in sync with upstream (ghostty-org/ghostty@4d605bf0d, 2026-07-30 — it was 216 commits behind) and adds the first automated test coverage for the fork's own features (sidebar stores, git panel, ghosttyctl, config options).

It is a single merge commit, so upstream history is preserved as-is and future syncs stay painless. GitHub will show 217 commits: 216 are upstream commits keeping their original authors, only the merge commit is mine.

Merge details

The merge was surprisingly clean — only 2 trivial conflicts:

File Conflict Resolution
.gitignore fork entries vs upstream's /sprite_face_test* / zig-pkg/ kept both, deduplicated zig-pkg/
src/build/Config.zig upstream replaced patch_rpath with the new patchelf mechanism, right next to the fork's macos_codesign_identity kept upstream's patchelf, preserved macos-codesign-identity

Everything else (sidebar, git panel, IPC server, ghosttyctl) merged without conflict — the fork's isolation into new files pays off.

Note: upstream now requires Zig 0.16.0 (was 0.15.2), and Xcode 26 needs the separately-downloaded Metal Toolchain (xcodebuild -downloadComponent MetalToolchain) for zig build test.

New tests

The fork's 50 commits shipped without tests; this PR adds coverage for the fork-specific code only:

  • cli/test_ghosttyctl.sh — 24 assertions running ghosttyctl against a fake IPC socket server: request shape for every command, JSON escaping (quotes, backslashes, newlines), GHOSTTY_TAB_ID targeting, and all error paths. Run with ./cli/test_ghosttyctl.sh, no app needed.
  • macos/Tests/Terminal/GitPanelModelTests.swift — 17 integration tests driving GitPanelModel against real throwaway git repositories: porcelain status parsing (modified/untracked/deleted/renames), detached HEAD, unborn branch, ahead/behind tracking, branch sorting, checkout/commit, and all three discard variants (the most destructive code in the fork).
  • macos/Tests/Terminal/SidebarStoresTests.swift — 10 unit tests for TabMetadataStore and NotificationStore.
  • src/config/Config.zig — defaults + parsing test for sidebar-fields / sidebar-git.

The Swift tests are picked up automatically by the existing GhosttyTests target (synchronized folder groups — no project.pbxproj changes).

Validation

All run on macOS 26.5 / Apple Silicon, Xcode 26.6, Zig 0.16.0:

Check Result
zig build (full app bundle)
zig build -Doptimize=ReleaseFast
zig build test (Zig core) ✅ 3109 passed, 16 skipped, 0 failed
xcodebuild test -only-testing:GhosttyTests ✅ all passed, incl. the 27 new tests
./cli/test_ghosttyctl.sh ✅ 24/24

pearkes and others added 30 commits July 3, 2026 16:12
libghostty-vt already parses OSC 52 into the clipboard_contents action but
the stream handler dropped it in the no-effect list, so embedders had no way
to observe a program's clipboard writes. Add a clipboard_set effect following
the existing bell/title_changed pattern and expose it through the C API as
GHOSTTY_TERMINAL_OPT_CLIPBOARD_SET.

The callback receives the OSC 52 kind byte and the base64 payload exactly as
received; decoding and kind interpretation are left to the embedder, matching
how ghostty itself defers decoding to the apprt layer.

Clipboard read requests ("?") are never forwarded: answering one would let
any program running in the terminal silently read the user's clipboard, and
a VT state library cannot mediate that with user consent. Empty payloads are
also ignored rather than inventing clear semantics.
PageList.scroll negated negative row deltas to obtain their
magnitude. minInt(isize) has no positive signed representation, so
callers could trigger a runtime safety panic before the existing
traversal had a chance to clamp at the top.

Use @abs to calculate an unsigned magnitude that represents every isize
value. The same value now drives both cached-pin and general traversal
paths.
Prompt scrolling negated negative deltas to count the requested jumps.
minInt(isize) has no positive signed representation, so a caller could
trigger a runtime safety panic before the search for an earlier prompt
started.

Use @abs to produce the full unsigned magnitude. An extreme negative
request now follows the normal prompt traversal and clamps at the oldest
available prompt.
Cell.screenPoint accumulated page row counts in CellCountInt even
though screen point Y coordinates are u32. Once scrollback crossed
65,535 rows, walking back through page metadata overflowed and trapped
in runtime safety builds.

Accumulate directly in u32 so page-local u16 row counts widen before
addition and the result uses the full range promised by point.Coordinate.
pointFromPin accumulated scrollback rows directly into the u32 Y
field. An unbounded PageList with more than 2^32 rows could overflow
while converting a valid pin and panic in runtime safety builds.

Use checked additions for every cross-page row contribution. If the
pin cannot fit in point.Coordinate, return null through the existing
out-of-range result instead of trapping.
Pin movement assumed every page had the same column count. During an
incomplete reflow, crossing into a narrower page could produce an
out-of-bounds x coordinate, while wrapped movement could land on the
wrong row or stop early.

Use destination page widths while moving vertically or wrapping, and
reject points that exceed the resolved page. Add synthetic mixed-width
coverage for movement, wrapping, overflow, and point conversion.
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.
SelectionGesture passed caller-supplied repeat timestamps directly to
Instant.since. A C API client or non-monotonic timer could provide an
earlier timestamp after a later one, causing a runtime safety panic
while converting negative elapsed seconds to u64.

Compare instants before calculating elapsed time and treat backwards
timestamps as failed repeats. The next press becomes a new single-click
anchor, matching other invalid repeat inputs.
Tabstops.reset subtracted one from the column count before iterating
default stops. Although init and resize accept zero columns, resetting
that state with a nonzero interval underflowed and panicked.

Return after clearing when the grid has fewer than two columns. Empty
and single-column tabstop sets now preserve the normal no-stop result.
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.
containedRowCached built full-row and rectangular selections using
the desired screen width. During incomplete reflow, an intermediate
narrower page received endpoints beyond its cell range, and consumers
could panic when resolving the returned pins.

Use the owning page width for full rows and clamp rectangular bounds to
that width. Every contained-row selection now returns resolvable pins.
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.
Selection drags converted caller-provided floating-point positions directly
to u32 and multiplied geometry dimensions without checking their range.
Non-finite positions, oversized values, overflowing dimensions, or empty
core geometry could therefore panic in runtime safety builds.

Clamp pixel positions to the representable u32 range, reject empty
geometry, and saturate the pixel span when dimensions overflow. Valid
drags keep their existing threshold behavior.
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.
Rectangle orientation swaps endpoint columns when a selection is mirrored.
During incomplete reflow, copying a column from a wider page onto the
narrower corner page created an invalid pin that panicked when resolved.

Clamp every swapped column to the page that owns the oriented corner.
Top-left and bottom-right calculations now always return valid pins.
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.
With mode 2027 disabled, the printer attaches zero-width codepoints without applying Unicode grapheme boundaries. Enabling the mode later and printing another non-ASCII codepoint asserted that every stored pair was part of one grapheme and panicked when it was not.

Feed all stored codepoints through the grapheme state machine and let it reset at existing boundaries before testing the new codepoint. The deterministic print comparison can now exercise live mode changes without clearing the screen first.
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.
setPwd appended the path bytes and terminating NUL with separate fallible operations. If only the terminator allocation failed, the function returned OutOfMemory but left a nonempty unterminated buffer that made getPwd panic on its sentinel check.

Reserve checked capacity for the complete sentinel-terminated value before clearing the old state, then use infallible appends. Allocation failure now leaves a valid prior value instead of exposing partial data.
setTitle appended the title bytes and terminating NUL with separate fallible operations. If only the terminator allocation failed, it returned OutOfMemory but left a nonempty unterminated buffer that made getTitle panic on its sentinel check.

Reserve checked capacity before clearing the existing title, then append both the title and terminator without further allocation. Allocation failure now leaves the prior valid title intact.
Terminal.resize deinitialized the current tab stops before allocating their replacement. If a resize beyond the inline tab-stop capacity ran out of memory, the terminal retained an undefined tab-stop value and normal deinitialization dereferenced a poisoned pointer.

Allocate and initialize the replacement first, then release the old tab stops only after allocation succeeds. A failed resize now retains the original tab stops and remains safe to destroy.
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.
setPwd can receive the slice returned by getPwd. Clearing the list retained its allocation, so appending that same slice used memcpy with aliased source and destination ranges and panicked in runtime-safe builds.

Resize the list within its reserved capacity and copy the value forward before writing the sentinel. This supports the complete current value and its subslices without weakening allocation-failure atomicity.
setTitle can receive the slice returned by getTitle. Clearing the list retained its allocation, so appending that same slice used memcpy with aliased source and destination ranges and panicked in runtime-safe builds.

Resize the list within its reserved capacity and copy the value forward before writing the sentinel. This supports the complete current value and its subslices without weakening allocation-failure atomicity.
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.
Partial history erasure can remove a page without marking tracked pins as garbage because they move coherently to the next page. ScreenSearch therefore retained a selection whose cached history result was subsequently removed by pruneHistory. Selecting again indexed an empty history result list and panicked.

Clear the tracked selection when its combined result index falls within the history suffix being pruned. Retained active and newer history selections keep their existing indices.
PageListSearch.feed changed only the node of its tracked progress pin. If the preceding history page had fewer rows or columns after a split, the retained bottom-right coordinates fell outside that page and the next PageList integrity check panicked.

Reset both coordinates to the new node's actual bottom-right cell whenever feed advances. The progress pin now remains valid across heterogeneous page sizes.
History pruning only compared cached serials with page_serial_min. Replacing a historical node through compaction left its old serial above that cutoff, so selecting the cached match attempted to track a destroyed node and hit the PageList validity assertion.

Validate every flattened chunk by finding the live node and matching its serial without dereferencing stale pointers. Remove only the invalid result and adjust any later selection index so unrelated older results remain available.
Search selection could run after the terminal removed a screen but before the next refresh reconciled the cached searchers. Reloading that stale ScreenSearch dereferenced freed PageList nodes, while normal cleanup also tried to untrack pins from the destroyed list.

Reconcile under the terminal lock before selecting, track ScreenSet generations so allocator address reuse cannot hide replacements, and release stale search buffers without touching pins already freed with the screen. Cleanup now takes the same lock when live pins must be untracked.
Count-limited PageIterator traversal took the minimum of the page and requested lengths, then tested whether that minimum exceeded the request. The condition was impossible, so iteration never crossed a page. Reverse traversal also excluded the current row and subtracted one from row zero, causing a runtime safety panic.

Count the current row in both directions, consume the returned length, and move to an adjacent page only when the request has rows remaining. Returned chunks now preserve their half-open bounds at row zero and across page boundaries.
jparise and others added 30 commits July 28, 2026 06:21
Start the vaxis event loop so the theme preview can receive terminal
input, and retain its environment map for as long as vaxis may access
it.
Start the vaxis event loop so the theme preview can receive terminal
input, and retain its environment map for as long as vaxis may access
it.
Triggered by [discussion
comment](ghostty-org#13491 (comment))
from @tristan957.

Vouch: @ruseel

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
GTK exposes the Wayland xdg_toplevel suspended state when the
compositor knows a window is not visible. Ghostty previously only used
widget map state, so it could continue rendering a mapped surface on an
inactive workspace or behind other windows.

Combine the mapped and suspended states for surface occlusion and update
all displayed surfaces whenever the toplevel suspension state changes.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fa965-aa5f-7099-85b4-a9679d2c8bd3
Applications cannot infer whether an unfocused terminal remains visible, so
focus reports are insufficient for avoiding expensive rendering while a
view is hidden.

Implement private mode 2033 and the visibility query/report sequences.
Track conservative per-surface visibility, report every effective change
while enabled, and always answer explicit queries and mode enables. Keep
view visibility across terminal resets because it is owned by the host,
not terminal state.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fa965-aa5f-7099-85b4-a9679d2c8bd3
…y-org#13494)

## Summary

Applications cannot reliably determine whether an unfocused terminal is
still
visible, so focus reports alone are insufficient for avoiding
unnecessary
rendering.

This adds terminal visibility reporting by:

- implementing private mode 2033
- supporting `CSI ? 998 n` visibility queries and `CSI ? 999 ; Ps n`
responses
- reporting effective visibility changes while mode 2033 is enabled
- preserving host-owned visibility state across terminal resets
- treating unknown visibility conservatively as potentially visible

On GTK 4.12 and newer, surface visibility now combines widget mapping
with the
toplevel `suspended` state. This allows Ghostty to recognize windows
hidden on
another workspace or otherwise known by the compositor to be
non-visible.
Older GTK versions retain the existing conservative behavior.

## Testing

Added coverage for:

- mode 2033 support and enable/disable behavior
- explicit visibility queries
- immediate reports when enabling the mode
- visible and non-visible responses
- visibility persistence across terminal resets
- suppression of visibility queries in read-only mode

## AI disclosure

Amp assisted with the implementation, tests, commit messages, and this
pull
request description. I reviewed the resulting changes and understand how
they
interact with the terminal, termio, surface, and GTK visibility paths.

Implements: ghostty-org#13451 
Reference: https://rockorager.dev/misc/visibility-reports/
Triggered by [discussion
comment](ghostty-org#13458 (comment))
from @jcollie.

Vouch: @RoniJacobson

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
reword: The doc said "macOS doesn't need any dependencies" and then immediately listed things you needed to install for macOS 😁.  This is just rewording the doc to be more consistent.
Triggered by
[comment](ghostty-org#13498 (comment))
from @mitchellh.

Vouch: @vegerot

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Includes better ZIg 0.16 compat and updates for Gnome 50.
ghostty-org#13491
ghostty-org#9921

Path matching previously included end-of-line spaces. Pi redraws can
leave blank cells after a path, causing cmd-click to open a pathname
that includes those cells. Do not include trailing whitespace in path
matches.

AI disclosure: Pi using GPT-5.6 Terra High was used to investigate
and write this change. I reviewed it personally.
Includes better ZIg 0.16 compat and updates for Gnome 50.
reword: The doc said "macOS doesn't need any dependencies" and then
immediately listed things you needed to install for macOS 😁. This is
just rewording the doc to be more consistent.
Triggered by [discussion
comment](ghostty-org#13508 (comment))
from @jcollie.

Vouch: @simonbcn

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
I've come up with a way to avoid manually allocating each entry which
honestly makes the code flow much more smoothly. Basically you collect
all the applicable keybinds first, then try to bind them with their
stable memory addresses.
I've come up with a way to avoid manually allocating each entry which
honestly makes the code flow much more smoothly. Basically you collect
all the applicable keybinds first, then try to bind them with their
stable memory addresses.
This change improves the user experience for Pi TUI users on macOS.

As a user of Ghostty 1.3.1, Pi 0.80.7, and macOS 26.5, I noticed that
Command-click was not working.

With Pi's help (GPT-5.6 Terra High), I narrowed the cause down to Pi's
redraw
behavior and `src/config/url.zig`'s regular expression. More details are
in
[Vouch Request
ghostty-org#13491](ghostty-org#13491).

The `trailing_spaces_at_eol` behavior in `src/config/url.zig` was
introduced in
[PR ghostty-org#9921](ghostty-org#9921) while
improving
Command-click handling for relative and local paths. The concern about
matching
trailing whitespace was also noted in [a review
comment](ghostty-org#9921 (comment)).

However, supporting file paths with trailing spaces does not seem like a
good
trade-off because it blocks Command-click for file paths displayed by Pi
TUI.

This PR removes that behavior.

I tested this on my Mac with a patched Ghostty build, and Command-click
worked
correctly for file paths in Pi TUI.

AI disclosure: I used Pi with GPT-5.6 Terra High to investigate and
implement this change.
I reviewed the code and tested the result myself.
Triggered by [discussion
comment](ghostty-org#13516 (comment))
from @jcollie.

Vouch: @fallintoplace

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Triggered by [discussion
comment](ghostty-org#13520 (comment))
from @pluiedev.

Vouch: @carlvillads

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Fixes ghostty-org#13522

Fixes unreachable when reflow dupes a hyperlink into a destination page 
whose string allocator is nearly full.

The capacity precondition in ReflowCursor.writeCell performed a single
test allocation of `uri.len + id.len` bytes before duping a hyperlink
into the destination page. But PageEntry.dupe allocates the URI and
the explicit ID as two separate allocations, and the string allocator
rounds every allocation up to its 32-byte chunk size independently, so
the two separate allocations can require one more chunk than the
single combined test allocation.

Write a new helper to make sure we get the right amount of space
using the same allocation pattern of dupe.
…13524)

Fixes ghostty-org#13522

Fixes unreachable when reflow dupes a hyperlink into a destination page
whose string allocator is nearly full.

The capacity precondition in ReflowCursor.writeCell performed a single
test allocation of `uri.len + id.len` bytes before duping a hyperlink
into the destination page. But PageEntry.dupe allocates the URI and the
explicit ID as two separate allocations, and the string allocator rounds
every allocation up to its 32-byte chunk size independently, so the two
separate allocations can require one more chunk than the single combined
test allocation.

Write a new helper to make sure we get the right amount of space using
the same allocation pattern of dupe.

**AI note:** Verified upstream via Fable. I told it to ignore any
conclusions and do its own validation and fix suggestion. It did
validate it with a failing test which I studied. It implement a fix, I
rewrote it to be more idiomatic.
Rename Builder.addPage and PageAllocation.cancel to their consistent allocatePage and deinit forms. Track successful ownership transfers so both builder APIs can use unconditional deferred cleanup without releasing pages transferred to a PageList.
Extracted out the raw `src/terminal` changes needed for the future
snapshot work, 4 separate changes. These are uncontroversial and
relatively simple, summarized below. Tests AI assisted but the rest
including commit messages, this PR message, etc. all organic.

* **Add iterator to ref counted set.** Iterate over live entries and
their IDs. Const, doesn't mutate the set.
* **lib.Enum produces stable enums for Zig.** Basically the same as C
except it uses the smallest fitting integer including the holes.
* **PageList: a couple helpers for manually creating pages.** There is
`PageList.Builder` for creating a new pagelist and
`PageList.allocatePage` for modifying an existing one. This allows
PageList construction from raw pages.
…features

Sync with ghostty-org/ghostty. Two trivial conflicts resolved:
- .gitignore: kept both sides
- src/build/Config.zig: upstream replaced patch_rpath with patchelf;
  kept it alongside the fork's macos-codesign-identity

New tests covering the fork's features:
- cli/test_ghosttyctl.sh: 24 assertions against a fake IPC socket
- macos/Tests/Terminal/SidebarStoresTests.swift: TabMetadataStore and
  NotificationStore unit tests
- macos/Tests/Terminal/GitPanelModelTests.swift: integration tests
  driving GitPanelModel against real throwaway git repos
- src/config/Config.zig: sidebar-fields / sidebar-git parsing test

Validated: zig build, zig build test (3109 passed), GhosttyTests 48/48,
test_ghosttyctl.sh 24/24.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.