Fixes#13276
Make CachedValue safe for concurrent terminal content reads and expiry.
The expiry task could previously release cached Swift String storage
while another thread retained it, aborting the process during otherwise
normal terminal use.
Protect cached values and task handles with an NSLock, and exercise
concurrent reads across repeated expiration in a regression test.
#13611
Route new-tab window presentation through an Objective-C exception catcher.
AppKit can raise an NSInternalInconsistencyException while selecting a
new tab in native fullscreen.
Catch the presentation exception, report it through the existing error
logging path, and leave Ghostty running when AppKit’s fullscreen window
stack is inconsistent.
This was pretty hard to reproduce but I was able to reproduce it about
1/3rd of the time via AppleScript automation...
OSC8 hyperlinks previously executed directly via the NSWorkspace opener
so a malicious application can just do whatever it wanted and trick the
user into opening something through Launch Services.
This PR notifies apprt of OSC8 hyperlinks so they can be handled
specially. In this PR, I added macOS-specific handling of OSC8 through a
variety of improvements:
- Preview text is sanitized, so invisible Unicode characters now show.
- Questionable-looking URLs require confirmation to open, but a user
can confirm to open.
- Very questionable or definitely unsafe URLs are blocked with an
alert that only allows the user to copy the link. The alert also
notifies the user why.
Fixes#13386
Defer transparent-titlebar KVO rebinding to the next main-queue turn.
Track the observed tab group so unchanged bindings are preserved.
Previously, a tab-group callback could invalidate and recreate its own
observation before returning, leaving closed terminal windows registered
with AppKit after the undo timeout. These windows accumulated titlebar and
layer state, increasing memory use and WindowServer CPU with tab churn.
Validated with an AppDelegate change that sat and created/closed tabs
in a loop, then counted weak controllers/windows/nsapp window.
Co-authored-by: Mustafa J <mustafa.0x@gmail.com>
#11799
Creating a CGEventTap without Accessibility permission leaks a Mach
port inside CoreGraphics on every failed attempt. The global keybind
listener retried this once per second while waiting for permission, so
Ghostty eventually exhausted the process port limit.
Request Accessibility access once, poll AXIsProcessTrusted while
access is denied, and create the event tap only after access is
granted. Stop polling before creation so an unrelated tap failure
cannot restart the leaking retry loop.
Adds "Copy" and "Export to file" buttons to the Terminal IO inspector
so recorded VT events can be saved outside the app for sharing or
analysis.
Export is wired up through a new export_terminal_io apprt action,
handled with a native save dialog on both macOS and GTK.
"Why is my quick terminal not taking up the entire top of my docked Mac
screen after I reconnect?" Boy howdy are you in the right PR.
It turns out that the quick terminal caches its last-closed window frame
per display so it can restore the user's size when reopened. The cache
entry was considered valid whenever the current screen was the same size
*or larger* than when the frame was saved ("persist when screens grow").
This has led to a pattern that was simply maddening. To wit: that rule
breaks across display changes.
When an external display is disconnected and later reconnected at a
different resolution (common after traveling with a laptop, do not even
get me started on projectors) the same display can come back larger than
when the frame was cached. The stale frame is still treated as valid and
restored, so the quick terminal no longer fills the screen (it appears
at a partial width/height). Because the cache is persisted, restarting
Ghostty does not clear it, and the user is slowly driven mad. Welcome to
madness; we have snacks.
This PR addresses this by treating a cached frame as valid when the
screen geometry matches exactly (both backing scale factor and frame
size). On any mismatch we drop the entry and fall back to the configured
quick-terminal-size. Manual resizes are still remembered across toggles
within a stable display configuration.
Fixes the regression reported in #12348.
AI disclaimer: I used AI for this. Of course I used AI for this, my code
is terrible on a good day. Specifically, Claude Code, as well as a
custom harness that has the curious tendency to write commit messages
containing conspiracy theories about the code because I am history's
greatest monster.
Fight me!
The quick terminal caches its last-closed window frame per display so it
can restore the user's size when reopened. The cache entry was considered
valid whenever the current screen was the same size *or larger* than when
the frame was saved ("persist when screens grow"). This has led to a pattern
that was simply maddening. To wit:
That rule breaks across display changes. When an external display is
disconnected and later reconnected at a different resolution (common
after traveling with a laptop) the same display can come back larger
than when the frame was cached. The stale frame is still treated as valid
and restored, so the quick terminal no longer fills the screen (it appears
at a partial width/height). Because the cache is persisted, restarting
Ghostty does not clear it, and the user is slowly driven mad.
Only treat a cached frame as valid when the screen geometry matches
exactly (both backing scale factor and frame size). On any mismatch we
drop the entry and fall back to the configured quick-terminal-size. Manual
resizes are still remembered across toggles within a stable display
configuration.
Fixes the regression reported in #12348.
Turns out combine's `publisher(for:,object:)` retains the object!
We verified this with a test script shown below. Fix this with a
manual filter. Found by @mustafa0x.
```
import Combine
import Foundation
final class Token {
deinit { print("Token deinitialized") }
}
weak var weakToken: Token?
var publisher: NotificationCenter.Publisher?
// Create scope that will free token.
do {
let token = Token()
weakToken = token
publisher = NotificationCenter.default.publisher(
for: Notification.Name("TestNotification"),
object: token
)
}
print("Retained:", weakToken != nil)
publisher = nil
print("Released:", weakToken == nil)
```
The core had no signal to the apprt when the active selection changed,
so a consumer (e.g. a screen reader) kept reading a stale selection
until some unrelated query refreshed it.
This change adds a payload-less selection_changed action that's fired on
a selection state transition. The apprt reads the current selection
through the normal read path.
This consolidates selection state changes so the notification fires
consistently: all sites route through setSelection rather than calling
screen.select directly, including the mouse paths that previously
bypassed it for clipboard timing.
The new setSelectionAndCopy extends setSelection with the additional
'copy_on_select' behavior.
On macOS, this posts .ghosttySelectionDidChange, which is debounced
before posting a NSAccessibility .selectedTextChanged notification.
GTK has no consumer yet and no-ops the action.
See: #9932
The core had no signal to the apprt when the active selection changed,
so a consumer (e.g. a screen reader) kept reading a stale selection
until some unrelated query refreshed it.
This change adds a payload-less selection_changed action that's fired on
a selection state transition. The apprt reads the current selection
through the normal read path.
This consolidates selection state changes so the notification fires
consistently: all sites route through setSelection rather than calling
screen.select directly, including the mouse paths that previously
bypassed it for clipboard timing.
The new setSelectionAndCopy extends setSelection with the additional
'copy_on_select' behavior.
On macOS, this posts .ghosttySelectionDidChange, which is debounced
before posting a NSAccessibility .selectedTextChanged notification.
GTK has no consumer yet and no-ops the action.
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.
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.
Use onSubmit for the plain Enter → next-match behavior, which respects
IME composition state. Keep onKeyPress only for Shift+Enter (previous
match), returning .ignored for plain Enter so the IME can process it.
Fixes 2 bugs
1. After dragging a non-focused surface from window A to window B
**quickly without making B the key window**, the focused surface in
window A is not receiving `keyDown` events.
https://github.com/user-attachments/assets/a8861c0a-9300-470d-bf7e-0f32a9ab2cd1
2. #12343 After dragging a surface from tab A to tab B within the same
window, the dragged surface is not rendering input correctly.
> The reason the thread is stuck is because the surface's occlusion
state is set to invisible after target tab's activate while dragging,
since the dragged surface is still in previous tree before dropping, and
after dropping the occlusion state of this surface is not updated to
visible, which causing the surface is accepting input but not rendering.
https://github.com/user-attachments/assets/d67f5dba-8609-4f67-a956-921982faf796