Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4015,7 +4015,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return true
}

// Visibility is used for focus gating, not for libghostty occlusion.
// Visibility is used for focus gating. Explicit portal visibility transitions
// also drive Ghostty occlusion so hidden workspace/split surfaces pause and
// queue a redraw when they become visible again.
fileprivate var isVisibleInUI: Bool { visibleInUI }
fileprivate func setVisibleInUI(_ visible: Bool) {
visibleInUI = visible
Expand Down Expand Up @@ -6590,6 +6592,7 @@ final class GhosttySurfaceScrollView: NSView {
private static let scrollToBottomThreshold: CGFloat = 5.0
private var isActive = true
private var lastFocusRefreshAt: CFTimeInterval = 0
private var lastRequestedPortalOcclusionVisible: Bool?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replay occlusion when the runtime surface changes.

lastRequestedPortalOcclusionVisible lives on the hosted view, but the actual ghostty_surface_t has its own lifecycle. If setVisibleInUI(_:) runs before createSurface(for:) finishes, or after the runtime surface is torn down/recreated, this cache can flip even though no occlusion was applied to the new surface. The next restore then sends only true, so the replacement surface never sees the hidden→visible transition this fix is relying on. Reset/replay the cached occlusion when a runtime surface is created/rebound, or key the dedupe off the current runtime surface instead of the view.

Based on learnings: "after calling view.forceRefreshSurface(), re-read self.surface because the surface may be torn down or reparented."

Also applies to: 7915-7918

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` at line 6595, The cached occlusion flag
lastRequestedPortalOcclusionVisible on GhosttyTerminalView can become stale
across runtime surface (ghostty_surface_t) lifecycles causing missed
hidden→visible transitions; update the logic so that when createSurface(for:)
finishes (or whenever the runtime surface is rebound/recreated, e.g. after
forceRefreshSurface()) you either reset lastRequestedPortalOcclusionVisible or
replay/apply the cached occlusion against the newly created surface so the new
surface sees the correct transition; modify createSurface(for:) (and the
surface-binding/rebind path) to clear or re-evaluate
lastRequestedPortalOcclusionVisible and call the same code path used by
setVisibleInUI(_:) to apply the occlusion to the fresh surface (or switch the
dedupe key to be per-current-surface rather than per-view).

private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
Expand Down Expand Up @@ -7909,6 +7912,10 @@ final class GhosttySurfaceScrollView: NSView {
let wasVisible = surfaceView.isVisibleInUI
surfaceView.setVisibleInUI(visible)
isHidden = !visible
if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
lastRequestedPortalOcclusionVisible = visible
surfaceView.terminalSurface?.setOcclusion(visible)
Comment on lines +7915 to +7917
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Occlusion state is cached before confirming a live terminal surface, which can drop the occlusion transition and leave Ghostty visibility state out of sync.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/GhosttyTerminalView.swift, line 7915:

<comment>Occlusion state is cached before confirming a live terminal surface, which can drop the occlusion transition and leave Ghostty visibility state out of sync.</comment>

<file context>
@@ -7909,6 +7912,10 @@ final class GhosttySurfaceScrollView: NSView {
         let wasVisible = surfaceView.isVisibleInUI
         surfaceView.setVisibleInUI(visible)
         isHidden = !visible
+        if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
+            lastRequestedPortalOcclusionVisible = visible
+            surfaceView.terminalSurface?.setOcclusion(visible)
</file context>
Suggested change
if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
lastRequestedPortalOcclusionVisible = visible
surfaceView.terminalSurface?.setOcclusion(visible)
if lastRequestedPortalOcclusionVisible != visible,
let terminalSurface = surfaceView.terminalSurface {
terminalSurface.setOcclusion(visible)
lastRequestedPortalOcclusionVisible = visible
}
Fix with Cubic

}
Comment on lines +7915 to +7918
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Deduplication guard records intent even when surface is nil

lastRequestedPortalOcclusionVisible is updated before the optional-chained setOcclusion call. If terminalSurface is nil at the time of the transition (e.g. the surface hasn't been attached yet), the flag is set to visible but Ghostty is never actually notified. A subsequent call with the same visible value will then be suppressed by the guard, leaving the surface's occlusion state out of sync with the recorded intent.

In practice this is unlikely to be hit on the normal workspace-switch path (surfaces are attached before workspaces are shown/hidden), but the pattern can be made robust by only recording the intent after a successful call:

Suggested change
if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
lastRequestedPortalOcclusionVisible = visible
surfaceView.terminalSurface?.setOcclusion(visible)
}
if wasVisible != visible {
let surface = surfaceView.terminalSurface
if lastRequestedPortalOcclusionVisible != visible, surface != nil {
lastRequestedPortalOcclusionVisible = visible
surface?.setOcclusion(visible)
}
}

Alternatively, unconditionally updating the flag and adding a comment noting the nil-surface race is also acceptable if the surface lifecycle guarantees absence of the edge case.

#if DEBUG
if wasVisible != visible {
let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)"
Expand Down
Loading