Fixes#12783 where opening the context menu (with right click) inside
the quick-terminal will hide the quick-terminal if autohide is enabled.
The cause of this issue is the quick-terminal window becoming inactive
and immediately active again when you open the context-menu. When the
window becomes inactive, the autohide feature hides the quick-terminal.
The temporary focus loss in GTK is triggered by GDK focus change events,
which probably originate from the windowing backend treating the context
menu as its own window. Whereas in GTK the context menu is not a
separate window but instead part of the widget tree of the window it was
opened from, so even when the context menu has focus that window is
still the active one in GTK.
As a fix `Window.propIsActive`, which implements the autohide logic,
will now do its work from a timeout callback, since there is probably no
reliable way to distinguish a temporary focus loss from a real one from
inside GTK and I'm not sure we can make any assumptions about the timing
of things happening in the windowing backend. A 100ms delay should be
long enough for the focus state to settle while still hiding the
quick-terminal quickly.
I reproduced the bug and verified the fix on Wayland with both Hyprland
and KDE. Temporary focus loss happens on X11+KDE as well, although it
doesn't matter there because there is no quick-terminal.
### AI Disclosure
No AI was used, code and comments were written by myself.
Add a render-state row-cells getter that encodes the current cell's full
grapheme cluster directly as UTF-8 into a caller-provided GhosttyBuffer.
The getter writes the base codepoint first, followed by any extra
grapheme codepoints, and follows the existing buffer-writer convention
where len is bytes written on success or required capacity on
GHOSTTY_OUT_OF_SPACE.
Previously C consumers could query grapheme codepoints, but bindings
that needed UTF-8 text had to reconstruct and encode the cluster
themselves. That duplicated terminal internals in downstream bindings
and made users pay for awkward cross-language struct handling. By owning
the UTF-8/grapheme behavior in libghostty, bindings can use one stable C
API and optionally wrap it with small binding-local helpers.
Add a render-state row-cells getter that encodes the current cell's
full grapheme cluster directly as UTF-8 into a caller-provided
GhosttyBuffer. The getter writes the base codepoint first, followed by
any extra grapheme codepoints, and follows the existing buffer-writer
convention where len is bytes written on success or required capacity
on GHOSTTY_OUT_OF_SPACE.
Previously C consumers could query grapheme codepoints, but bindings
that needed UTF-8 text had to reconstruct and encode the cluster
themselves. That duplicated terminal internals in downstream bindings
and made users pay for awkward cross-language struct handling. By
owning the UTF-8/grapheme behavior in libghostty, bindings can use one
stable C API and optionally wrap it with small binding-local helpers.
Add a render row-cells data key for querying whether the current cell
has explicit styling. This lets consumers avoid fetching a raw cell or
full style snapshot when all they need is the cell's HasStyling bit.
The new key is appended to the existing enum for ABI safety and is
served by the existing row-cells getter path. Existing data keys and
function exports are unchanged.
This was identified as an allocation hot-spot in Go renderers.
Add a render row-cells data key for querying whether the current cell has
explicit styling. This lets consumers avoid fetching a raw cell or full style
snapshot when all they need is the cell's HasStyling bit.
The new key is appended to the existing enum for ABI safety and is served by
the existing row-cells getter path. Existing data keys and function exports are
unchanged.
Expose whether the terminal viewport is currently pinned to the active
area through the libghostty-vt terminal data API. Previously embedders
could only infer this from scrollbar geometry, which was indirect and
could require the more expensive scrollbar calculation.
The new GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE value returns the exact
PageList viewport state as a bool. The scroll viewport test now verifies
the value while moving between the active area and scrollback.
Expose whether the terminal viewport is currently pinned to the active
area through the libghostty-vt terminal data API. Previously embedders
could only infer this from scrollbar geometry, which was indirect and
could require the more expensive scrollbar calculation.
The new GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE value returns the exact
PageList viewport state as a bool. The scroll viewport test now verifies
the value while moving between the active area and scrollback.
Close#12825
Skip the initial emissions from the focused surface appearance
publishers after a tab focus change. The focused surface is already
synced immediately, so the initial Combine values only repeat the same
titlebar and background updates. Subsequent derived config and OSC
background changes still resync the window appearance.
https://github.com/user-attachments/assets/f229fb95-4b4c-4040-85ac-0acfcc54ca82
Assigned to Codex GPT 5.5(medium)
PS: Sry for I don't write zig and let AI write this.
This change primarily focused on a revised +ssh-cache user interface,
but it also reworks a bunch of the internals.
The primary CLI improvement is support for positional arguments and a
consistent list output format that includes both the ISO-formatted
timestamp and relative age.
ghostty +ssh-cache # List all cached destinations
ghostty +ssh-cache user@example.com # Show that destination
ghostty +ssh-cache example.com # Show all users on that host
ghostty +ssh-cache --add=user@example.com # Manually add a destination
ghostty +ssh-cache --remove=user@example.com # Remove a destination
ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
ghostty +ssh-cache --clear # Clear entire cache
Notable, we now support a --prune operation that replaces the previous
--expire-days flag that was never actually hooked up to anything (!!).
--prune also supports a wider range of Duration-based values.
We're also much more consistent with error codes: 0=success, 1=failure,
2=usage.
While working on those changes, I also reworked the cache internals,
particularly the code around timestamp handling and errors. For example,
I dropped the explicit error sets because they were growing unwieldy,
and in practice we only matched on a subset of those errors.
Lastly, overall test coverage should be much improved, especially around
the time- and allocation-related operations.
---
*AI Disclosure:* I made a lot of iterative, AI-assisted (Claude Opus
4.7) correctness passes over this work. It was particularly helpful in
tracing through the various failure modes, and it wrote those unit tests
in the process.
## Summary
`SurfaceView` caches the background color set by OSC 11 in
`backgroundColor`. `TerminalWindow.preferredBackgroundColor` consults
that cache before falling back to `derivedConfig.backgroundColor`, so
once OSC 11 has fired the cached value masks any later config change.
After a light/dark theme auto-switch (e.g. `theme =
light:my-light,dark:my-dark`) this leaves the window chrome on the
previous theme's color until the application next emits OSC 11.
In `ghosttyConfigDidChange`, after updating `derivedConfig`, drop the
cache when it no longer matches the new config-derived background. A
subsequent `ghosttyColorDidChange` repopulates it as before, so
within-config OSC 11 behavior is unchanged.
## Reproduction
1. Configure `theme = light:SomeLight,dark:SomeDark` where the two
themes have visibly different background colors.
2. Open a terminal session where any application (e.g. a shell startup
script) has sent OSC 11 to set a custom background color.
3. Switch macOS appearance (System Settings → Appearance).
4. **Before**: window chrome stays the previous theme's color until the
terminal next emits OSC 11.
5. **After**: window chrome immediately updates to the new theme's
background color.
## Changes
- `SurfaceView_AppKit.swift` — one guard: if the cached
`backgroundColor` disagrees with the new
`derivedConfig.backgroundColor`, set it to `nil`.
Refactor terminal text selection into a reusable `SelectionGesture`
state machine. Most importantly, this means our click+drag logic around
selection is now fully unit tested! And we found bugs! And fixed them!
The large line increase in this diff is mainly comments + tests.
I've wanted to do this forever so we can unit test this, but I was
kicked in the butt to do it recently because reimplementing selection
logic in libghostty consumers turns out to be complex and error prone
and we have a perfectly battle tested logic machine here so why not
extract it?
Behavioral changes from main surfaced via unit testing:
- Dragging now drags by output across semantic output blocks when the
initial press was an output selection. This matches the behavior of
dragging continuing whatever the initial selection logic was.
- Selection autoscroll now stops when the click anchor is invalidated by
a screen change (e.g. primary to alt)
- Deep press (macOS force touch) now selects the word at the original
press location and consumes the active drag gesture, preventing later
movement from dragging or autoscrolling that selection. This matches
built-in macOS apps.
- Mouse release records whether the gesture moved away from the pressed
cell, so link and prompt clicks are skipped after a drag while normal
clicks still activate them.
Example usage:
```zig
var gesture: terminal.SelectionGesture = .init;
defer gesture.deinit(t);
const press_selection = try gesture.press(t, .{
.time = try std.time.Instant.now(),
.pin = press_pin,
.xpos = mouse_x,
.ypos = mouse_y,
.max_distance = cell_width,
.repeat_interval = mouse_interval,
.word_boundary_codepoints = selection_word_chars,
.behaviors = &.{ .cell, .word, .output },
});
try t.screens.active.select(press_selection);
if (gesture.drag(t, drag_event)) |drag_selection| {
try t.screens.active.select(drag_selection);
}
gesture.release(t, .{ .pin = release_pin });
```
Selection gestures now treat releases with invalidated anchors as dragged,
so a press that crosses screen boundaries cannot also activate links or
prompt clicks on release. Cell drags that create a same-cell selection also
mark the gesture as dragged, which keeps click-only actions from firing
after a threshold-crossing drag.
Autoscroll now resolves the drag pin after moving the viewport instead of
reusing the pin from before the scroll. This keeps the selection aligned
with the row currently under the pointer. The inspector also validates the
tracked click pin before displaying it so stale pins from inactive screens
are ignored.
Close#12825
Skip the initial emissions from the focused surface appearance publishers after a tab focus change. The focused surface is already synced immediately, so the initial Combine values only repeat the same titlebar and background updates. Subsequent derived config and OSC background changes still resync the window appearance.
SurfaceView caches the background color set by OSC 11 in
backgroundColor. TerminalWindow.preferredBackgroundColor consults
that cache before falling back to derivedConfig.backgroundColor,
so once OSC 11 has fired the cached value masks any later config
change. After a light/dark theme auto-switch this leaves the
window chrome on the previous theme's color until the application
next emits OSC 11.
In ghosttyConfigDidChange, after updating derivedConfig, drop the
cache when it no longer matches the new config-derived background.
A subsequent ghosttyColorDidChange repopulates it as before, so
within-config OSC 11 behavior is unchanged.
## Problem
Every audio bell calls `gtk.MediaFile.newForFilename`, which spins up a
full GStreamer pipeline. The GTK4 GStreamer backend's GL sink starts
`gstglcontext`/`gldisplay-event` threads that are **never joined on
teardown**, so allocating a fresh `MediaFile` per ring leaks a pipeline
and ~4 threads on every bell. The old `notify::ended -> unref` handler
discarded the pipeline but did not (and could not) join those threads.
A long-running instance accumulated **705 threads over ~4h** of normal
use.
## Fix
Cache one `MediaFile` per surface (`priv.bell_media`), rebuilt only when
`bell-audio-path` changes and unref'd on `dispose`. Each bell now
replays the same pipeline via `seek(0)` + `play()` instead of creating a
new one. `seek(0)` is required so an ended stream plays again (cf.
#8957).
## Verification
Confirmed on a real running instance with the fix: GStreamer's global
element counter only ever reached `oggdemux4` over an hour of use (one
pipeline per bell-ringing surface, reused for every subsequent bell) and
the process thread count stayed flat — versus the per-bell growth
before.
## Commits
1. **The fix** — reuse one MediaFile per surface.
2. **Unit regression test** — guards the `bellMediaFile` reuse contract
(same path → same object, changed path → rebuild). Runs in the existing
`test-gtk` CI job; needs no display.
3. **End-to-end CI job** *(kept separate so it can be dropped
independently)* — `test/bell-leak.sh` + a `test-gtk-bell-leak` workflow
job that runs ghostty headless (Xvfb + software GL), rings 120 bells,
and fails if the thread count grows per-bell. It's heavier and more
environment-sensitive (needs Xvfb/Mesa/GStreamer on the runner), so it's
isolated for easy review/removal.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds a bell-leak-check-gnome NixOS test (nix/tests.nix) that launches
Ghostty under GNOME on Wayland, rings 100 bells in the window, and fails
if the GUI process thread count grows per-bell — the end-to-end
signature of the GStreamer pipeline leak fixed in this branch. Verified
locally: growth of ~1 thread over 100 bells, vs ~+400 pre-fix.
Replaces the earlier Xvfb shell script + workflow job: per review, X11
support in GNOME is going away, and this belongs as a Nix check
alongside the other *-gnome tests rather than a standalone script.
The VM has no GPU, so it renders via llvmpipe; the test gives the guest
enough cores/RAM for software GL and tolerates the +new-window D-Bus
activation exceeding its client-side timeout (the window still comes up)
by waiting for the window rather than hard-failing on the call.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Guards the contract that prevents the bell thread leak: bellMediaFile
must return the same cached MediaFile for an unchanged path and only
rebuild when the path changes. A revert to per-bell allocation (the
leak) would fail this. Runs in the existing test-gtk CI job; needs no
display or playback since the path bookkeeping is all that's asserted.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Each audio bell called gtk.MediaFile.newForFilename, which spins up a
full GStreamer pipeline. The GTK4 GStreamer backend's GL sink starts
gstglcontext/gldisplay-event threads that are never joined on teardown,
so allocating a MediaFile per ring leaked a pipeline and ~4 threads on
every bell. A long-running instance accumulated 705 threads over ~4h of
normal use.
Cache one MediaFile per surface (priv.bell_media), rebuilt only when
bell-audio-path changes and unref'd on dispose. Each bell now replays
the same pipeline via seek(0)+play() instead of creating a new one. The
notify::ended -> unref handler is removed: it was what discarded (and
leaked) a pipeline per ring. seek(0) is required so an ended stream
plays again (#8957).
Verified on a real instance: GStreamer's global element counter reached
only oggdemux4 over an hour of use (one pipeline per bell-ringing
surface, reused) and thread count stayed flat, versus per-bell growth
before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This change primarily focused on a revised +ssh-cache user interface,
but it also reworks a bunch of the internals.
The primary CLI improvement is support for positional arguments and a
consistent list output format that includes both the ISO-formatted
timestamp and relative age.
ghostty +ssh-cache # List all cached destinations
ghostty +ssh-cache user@example.com # Show that destination
ghostty +ssh-cache example.com # Show all users on that host
ghostty +ssh-cache --add=user@example.com # Manually add a destination
ghostty +ssh-cache --remove=user@example.com # Remove a destination
ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
ghostty +ssh-cache --clear # Clear entire cache
Notable, we now support a --prune operation that replaces the previous
--expire-days flag that was never actually hooked up to anything (!!).
--prune also supports a wider range of Duration-based values.
We're also much more consistent with error codes: 0=success, 1=failure,
2=usage.
While working on those changes, I also reworked the cache internals,
particularly the code around timestamp handling and errors. For example,
I dropped the explicit error sets because they were growing unwieldy,
and in practice we only matched on a subset of those errors.
Lastly, overall test coverage should be much improved, especially around
the time- and allocation-related operations.