Skip to content

terminal: propagate NSWindow occlusion to Ghostty surface occlusion - #7621

Closed
austinywang wants to merge 2 commits into
mainfrom
issue-7596-window-occlusion
Closed

austinywang wants to merge 2 commits into
mainfrom
issue-7596-window-occlusion

Conversation

@austinywang

@austinywang austinywang commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of #7596 (memory audit, slice 4). Relates to #7186.

cmux drove ghostty_surface_set_occlusion from portal/UI visibility only (workspace/tab selection); window-level occlusion was a documented no-op (updateOcclusionState(), dead code). Result: with the cmux window fully covered by another app or miniaturized, the mounted workspace's surfaces kept rendering — the audit's 10s sample showed renderer.updateFrame/drawFrame hot stacks for surfaces that were not on screen.

Design

  • New SurfaceOcclusionState (CmuxTerminal package): two axes, uiVisible and windowVisible; Ghostty sees uiVisible && windowVisible.
  • TerminalSurface.setOcclusion(_:) keeps its signature and becomes the UI-axis setter (existing callers — portal setVisibleInUI, canvas panes — unchanged in semantics since the window axis defaults to visible). New setWindowOcclusionVisible(_:) sets the window axis. Both funnel through one deduplicated apply.
  • Each hosted terminal view observes NSWindow.didChangeOcclusionStateNotification for its current window (registered next to the existing screen-change observer, removed on detach/deinit), pushes the current state once on attach, and pushes nothing on detach — reparent transients keep the last window state, preserving the intent of the old no-op ("avoid transient clears during reparenting").
  • Latent bug fixed: surface (re)creation now replays the current effective occlusion (previously only focus was replayed, so a surface created while hidden rendered as if visible).

Tests

New SurfaceOcclusionStateTests (CmuxTerminal package, Swift Testing): defaults, full AND truth table, hide/show sequences. swift test in Packages/macOS/CmuxTerminal: 56 tests in 10 suites pass (the runner's XCTest arch preflight exits nonzero in this environment; Swift Testing reports all passing — same artifact as previous package runs). A live-render dedup test against the C seam isn't feasible without new stub instrumentation; the rendering-stops-when-covered behavior itself is runtime/perf work verified by the #7596 re-measurement procedure.

python3 scripts/swift_file_length_budget.py passes with no TSV changes (GhosttyTerminalView.swift 12,506 ≤ 12,511; TerminalSurface.swift 605 ≤ 607; RuntimeLifecycle stays exactly at 655).

No user-facing strings → no localization changes.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Changes when Ghostty stops rendering (visibility/occlusion path) and touches main-thread window notifications; behavior is localized but affects renderer lifecycle and off-screen CPU/GPU use.

Overview
Stops Ghostty from rendering when the cmux window is covered or miniaturized, not only when the portal hides the pane. Previously ghostty_surface_set_occlusion followed UI/portal visibility; window occlusion was intentionally ignored (including a no-op updateOcclusionState()), so off-screen windows could still drive drawFrame work.

Introduces SurfaceOcclusionState with uiVisible and windowVisible; Ghostty receives uiVisible && windowVisible. Existing setOcclusion(_:) updates the UI axis; new setWindowOcclusionVisible(_:) updates the window axis. Both paths dedupe via lastAppliedOcclusionVisible before calling ghostty_surface_set_occlusion.

GhosttyTerminalView observes NSWindow.didChangeOcclusionStateNotification, seeds window visibility on surface attach, and refreshes it when the view enters a window. Detach does not reset the window axis (same reparenting behavior as the old no-op). On runtime surface (re)creation, effective occlusion is replayed alongside focus so surfaces born while hidden do not render until both axes are visible. Occlusion tracking resets on agent-hibernation suspend.

Adds SurfaceOcclusionStateTests for defaults, AND logic, and hide/show ordering.

Reviewed by Cursor Bugbot for commit 00343ea. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Propagates NSWindow occlusion to Ghostty surface occlusion so a surface renders only when it’s visible in the UI and the window is visible. Also seeds window visibility when attaching a surface to a view already in a window, so hidden windows don’t keep rendering.

  • Bug Fixes
    • Added SurfaceOcclusionState (uiVisible AND windowVisible) in CmuxTerminal; setWindowOcclusionVisible(_:) is new, setOcclusion(_:) remains the UI-axis setter. Calls to ghostty_surface_set_occlusion are deduplicated.
    • GhosttyTerminalView observes NSWindow.didChangeOcclusionStateNotification, seeds the window axis on window attach and when attaching a surface to an in-window view, and leaves it unchanged during detach/reparent.
    • Replays effective occlusion on surface (re)creation so hidden surfaces don’t render until visible. Added SurfaceOcclusionStateTests covering defaults, AND semantics, and hide/show sequences.

Written for commit 00343ea. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added two-axis occlusion visibility tracking (UI and window) with an automatically computed effective state.
    • The terminal view now synchronizes window occlusion changes into the active terminal surface.
  • Bug Fixes
    • Ensures occlusion visibility is reapplied correctly after surface creation/resume and is cleared appropriately during hibernation.
    • Prevents redundant occlusion updates when the effective visibility hasn’t changed.
  • Tests
    • Added tests covering default visibility, effective visibility logic, and visibility transitions.

Ghostty occlusion was driven only by portal/UI visibility; NSWindow
occlusion was a documented no-op, so every mounted surface in a fully
covered or miniaturized cmux window kept rendering (updateFrame hot
stacks in the #7596 audit; relates #7186).

Make Ghostty occlusion the AND of two axes combined inside
TerminalSurface: UI visibility (existing setOcclusion callers) and a
new window axis driven per hosted view by
NSWindow.didChangeOcclusionStateNotification, pushed once on window
attach and deliberately left unchanged across detach/reparent
transients. Calls into ghostty_surface_set_occlusion are deduplicated,
and surface (re)creation now replays the current effective occlusion —
previously a surface created while hidden rendered as if visible.

Part of #7596

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 8, 2026 11:42pm
cmux-staging Building Building Preview, Comment Jul 8, 2026 11:42pm

@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a two-axis surface occlusion state, threads it through TerminalSurface, resets and reapplies it during runtime lifecycle transitions, and connects GhosttyNSView to AppKit occlusion change notifications.

Changes

Two-axis Surface Occlusion Tracking

Layer / File(s) Summary
SurfaceOcclusionState model and tests
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/SurfaceOcclusionState.swift, Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/SurfaceOcclusionStateTests.swift
Adds SurfaceOcclusionState with uiVisible, windowVisible, and effectiveVisible, plus tests for defaults, boolean combination, and state sequencing.
TerminalSurface occlusion application and stored state
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift, Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift
Adds occlusion state storage, changes setOcclusion to update the UI axis, introduces setWindowOcclusionVisible, and routes both through shared deduped occlusion application.
Runtime lifecycle occlusion reset and reapplication
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
Clears the cached occlusion value on suspend and reapplies effective occlusion after surface creation.
GhosttyNSView window occlusion observer
Sources/GhosttyTerminalView.swift
Stores a second observer, seeds initial occlusion from the window, listens for occlusion-state changes, and tears down both observers.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NSWindow
  participant GhosttyNSView
  participant TerminalSurface
  participant ghostty_surface_set_occlusion

  NSWindow-->>GhosttyNSView: didChangeOcclusionStateNotification
  GhosttyNSView->>TerminalSurface: setWindowOcclusionVisible(visible)
  TerminalSurface->>TerminalSurface: occlusionState.windowVisible = visible
  TerminalSurface->>TerminalSurface: applyOcclusionIfNeeded()
  alt effectiveVisible changed
    TerminalSurface->>ghostty_surface_set_occlusion: set_occlusion(effectiveVisible)
    TerminalSurface->>TerminalSurface: lastAppliedOcclusionVisible = effectiveVisible
  end
Loading
🚥 Pre-merge checks | ✅ 25
✅ Passed checks (25 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New occlusion state is a plain Sendable value type; the added setters/observer run on AppKit/main-queue paths and no new Sendable reference or background UI access was introduced.
Cmux Swift Blocking Runtime ✅ Passed PR delta only seeds window occlusion on attach; no waits, sleeps, main-queue sync, semaphores, or new locks were added.
Cmux Browser Automation Off-Main ✅ Passed PR only changes macOS terminal occlusion plumbing; it does not touch the browser automation files or browser.* worker routing the rule covers.
Cmux Expensive Synchronous Load ✅ Passed PASS: The new main-actor UI callbacks only set occlusion state and call Ghostty; no agent-history loaders or large JSON/transcript parsing were added.
Cmux Cache Substitution Correctness ✅ Passed PASS: the new cache only dedupes transient occlusion updates; cold starts and recreates replay effective state, and teardown clears the cache. No persistence/history/snapshot path.
Cmux No Hacky Sleeps ✅ Passed PASS: the diff touches only Swift occlusion plumbing; no sleeps, timers, polling, or fixed delays were introduced.
Cmux Algorithmic Complexity ✅ Passed The new work is O(1): one boolean state, one cached apply, and only a fixed 2-item observer cleanup array; no scalable rescans were introduced.
Cmux Swift Concurrency ✅ Passed Changes are synchronous state plumbing plus an AppKit occlusion observer; no new background Dispatch, Combine app-state, completion APIs, or uncancelled Tasks in the diff.
Cmux Swift @Concurrent ✅ Passed PASS: The only changed code is a synchronous main-thread occlusion seed in viewDidMoveToWindow; no new async helpers or @concurrent/actor-isolation changes appear in the diff.
Cmux Swift File And Package Boundaries ✅ Passed New logic is split into a 27-line package type and small package/app glue; touched 12.5k-line app file grew only 21 lines and remains incidental.
Cmux Swiftpm Lockfiles ✅ Passed Diff only changes Sources/GhosttyTerminalView.swift; no Package.resolved, .gitignore, workflow, or package-reference files are touched.
Cmux Swift Logging ✅ Passed HEAD only seeds window occlusion in GhosttyTerminalView; no new or changed print/debugPrint/dump/NSLog/Logger usage appears in the diff.
Cmux User-Facing Error Privacy ✅ Passed Diff only adds occlusion-state plumbing/tests and no user-facing errors, alerts, or recovery copy exposing private details.
Cmux Full Internationalization ✅ Passed Patch only seeds window occlusion in GhosttyTerminalView; no user-facing text, localization API, string-catalog, web-message, or plist changes were introduced.
Cmux Swiftui State Layout ✅ Passed PASS: The PR only adds AppKit bridge occlusion observers and legacy TerminalSurface model state; no new @Observable/@StateObject/GeometryReader/lazy-row render-time mutation.
Cmux Architecture Rethink ✅ Passed Required AppKit bridge with clear single owner (TerminalSurface) and documented invariant; no timing hacks or competing lifecycle owners introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed Only GhosttyNSView was changed; no standalone NSWindow/NSPanel/WindowGroup or cmux identifier routing was added, so the auxiliary-window rule doesn’t apply.
Cmux Source Artifacts ✅ Passed All changed paths are hand-written Swift source/tests; the policy explicitly passes source and test files, and no logs/temp/build artifacts appear in the diff.
Cmux No Test Or Debug Seam In Production Source ✅ Passed The diff only seeds window occlusion on attach in production code; no new DEBUG/test-only seam, wrapper, or test-hook accessor was added.
Cmux No Ambient Global State ✅ Passed No ambient globals added: the PR introduces an instance value type plus instance methods/properties only; no file-scope API funcs, mutable vars, or new singletons.
Title check ✅ Passed Clear, concise title matching the main change: propagating window occlusion into Ghostty surface occlusion.
Description check ✅ Passed Summary and testing are detailed and on-topic; a few non-critical template sections are left unfilled.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-7596-window-occlusion

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 8, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires AppKit window occlusion into Ghostty surface rendering. The main changes are:

  • Adds a two-axis surface visibility model for UI and window visibility.
  • Applies the combined visibility state through the existing Ghostty occlusion call.
  • Seeds window visibility when a terminal surface is attached to a windowed view.
  • Observes NSWindow occlusion changes for hosted terminal views.
  • Replays effective occlusion when a runtime surface is created.
  • Adds tests for the new visibility model.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/SurfaceOcclusionState.swift Adds the value model for combining UI and window visibility.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift Routes UI and window visibility through a deduplicated occlusion update.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift Replays occlusion state when creating a runtime surface.
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift Stores the current occlusion state and last applied value.
Sources/GhosttyTerminalView.swift Seeds and observes window occlusion for hosted terminal views.
Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/SurfaceOcclusionStateTests.swift Adds tests for default and combined visibility behavior.

Reviews (2): Last reviewed commit: "terminal: seed window occlusion when a s..." | Re-trigger Greptile

guard let self, let window, self.window === window else { return }
self.terminalSurface?.setWindowOcclusionVisible(window.occlusionState.contains(.visible))
}
terminalSurface?.setWindowOcclusionVisible(window.occlusionState.contains(.visible))

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.

P1 Assigned Surface Misses Occlusion

When a GhosttyNSView already belongs to a miniaturized or fully covered window and a TerminalSurface is assigned afterward, this window-axis seed has already run. The new surface keeps windowVisible at its default true until a later occlusion notification, so a surface created in an already-hidden window can keep rendering off-screen.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 00343ea: attachSurface(_:) now seeds the window-occlusion axis from the current window (window.occlusionState.contains(.visible)) when the view is already in a window, so a surface attached after viewDidMoveToWindow ran no longer keeps the default windowVisible = true inside a miniaturized/covered window.

/// Ghostty should render only when the surface is both visible in the UI
/// (portal/canvas visibility) and its host window is visible according to
/// `NSWindow.occlusionState`.
public struct SurfaceOcclusionState: Equatable, Sendable {

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 Implicit Actor Isolation Surface

This new public Sendable value model is used by nonisolated TerminalSurface methods and main-actor lifecycle code, but it is not declared nonisolated. Under cmux's Swift 6 actor-isolation rule, pure Sendable models should opt out explicitly so later compiler or module isolation changes do not couple this runtime state to the main actor and produce isolation diagnostics in the setter paths.

Suggested change
public struct SurfaceOcclusionState: Equatable, Sendable {
nonisolated public struct SurfaceOcclusionState: Equatable, Sendable {

Rule Used: Flag new or materially worsened Swift 6 actor isol... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining this one: the CmuxTerminal package compiles in plain Swift 6 language mode with no defaultIsolation(MainActor.self) setting, so a top-level public struct here is already nonisolated. Every sibling public Sendable value type in the package (TerminalSurfaceSpawnPolicy, TerminalSurfaceRuntimeFilesystem, TerminalSurfaceRegistryDiagnosticSnapshot) is declared without an explicit nonisolated keyword, so adding it only to SurfaceOcclusionState would deviate from the package's existing convention rather than follow it.

…ow view

viewDidMoveToWindow only seeds the window-occlusion axis for the surface
attached at that moment. A surface attached later to a view already sitting
in a miniaturized or fully covered window kept the default windowVisible
state and continued rendering off-screen. Seed the axis from the current
window in attachSurface (Greptile P1 on #7621).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 00343ea. Configure here.

// sitting in an occluded window does not keep rendering off-screen.
if let window {
surface.setWindowOcclusionVisible(window.occlusionState.contains(.visible))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Window occlusion seeded after create

Medium Severity

setWindowOcclusionVisible runs after attachToView, which can synchronously createSurface and apply occlusionState while windowVisible still defaults to true. An occluded host window can briefly get ghostty_surface_set_occlusion(true) plus forceRefreshSurface/ghostty_surface_refresh before the window axis is corrected.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 00343ea. Configure here.

This branch was successfully deployed

1 active deployment
Preview – cmux — 00343eac Deployed Jul 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants