Skip to content

Fix blank terminal renders after workspace switches#1964

Merged
austinywang merged 1 commit intomainfrom
issue-1789-terminal-blank-ws-v2
Mar 23, 2026
Merged

Fix blank terminal renders after workspace switches#1964
austinywang merged 1 commit intomainfrom
issue-1789-terminal-blank-ws-v2

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Mar 23, 2026

Closes #1789

Summary

  • Drive Ghostty occlusion from terminal portal visibility transitions during workspace hide/show.
  • Use Ghostty's native hidden/visible render resume path instead of forcing ad hoc surface refreshes when a workspace comes back.
  • Fix the stale blank/white terminal surfaces that could appear after switching away from and back to a workspace.

Testing

  • Built and launched the tagged Debug app with ./scripts/reload.sh --tag issue-1789-v2.
  • Used the tagged cmux CLI to switch workspaces back and forth to exercise the workspace handoff path.
  • Did not run local automated tests per repo policy.
  • Did not add an automated test because this is a portal-hosted macOS rendering lifecycle bug and there is no meaningful existing executable seam for it; source-shape tests would violate the repo's test policy.

Demo Video

  • Video URL or attachment: none

Review Trigger (Copy/Paste as PR comment)

@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review

Checklist

  • I tested the change locally
  • I added or updated tests for behavior changes
  • I updated docs/changelog if needed
  • I requested bot reviews after my latest commit (copy/paste block above or equivalent)
  • All code review bot comments are resolved
  • All human review comments are resolved

Summary by cubic

Fixes blank/white terminal surfaces after switching workspaces by driving Ghostty occlusion from portal visibility transitions and relying on the native pause/resume render path. This removes the need for forced surface refreshes and ensures a redraw when a workspace becomes visible.

  • Bug Fixes
    • Trigger terminalSurface?.setOcclusion(visible) on portal visibility changes so hidden surfaces pause and resume with a redraw.
    • Prevent redundant occlusion toggles by tracking lastRequestedPortalOcclusionVisible; keep view visibility for focus gating only.

Written for commit aa24f12. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved terminal visibility state management to ensure proper synchronization when the application window visibility changes.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 23, 2026

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Mar 23, 2026 1:06am

@chatgpt-codex-connector
Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 23, 2026

📝 Walkthrough

Walkthrough

The change adds explicit occlusion control to terminal surfaces based on portal visibility state transitions. When a portal's visibility changes, the code now calls setOcclusion(visible) to ensure the terminal surface rendering pipeline is properly refreshed.

Changes

Cohort / File(s) Summary
Terminal Surface Occlusion Control
Sources/GhosttyTerminalView.swift
Added state tracking for portal occlusion visibility; updated setVisibleInUI(_:) to explicitly drive Ghostty occlusion via setOcclusion(visible) when portal visibility transitions occur, ensuring terminal surfaces refresh properly on visibility changes.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~4 minutes

Possibly related PRs

  • #1050 — Modifies portal visibility logic in GhosttyTerminalView.swift with similar focus on portal rebinding and visibility-driven surface updates.

Poem

🐰 When portals fade and scenes shift clear,
Our surfaces must know they're here—
One signal sent to break the freeze,
The rabbit hops through displays with ease! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main fix: preventing blank terminal renders after workspace switches, which aligns with the primary change of driving Ghostty occlusion from portal visibility.
Description check ✅ Passed The description includes summary, testing methodology, and rationale for not adding automated tests. However, no demo video is provided and the checklist shows incomplete status on several items.
Linked Issues check ✅ Passed The code changes directly address issue #1789 by implementing occlusion-driven rendering via portal visibility transitions [#1789], preventing blank terminal surfaces after workspace switches.
Out of Scope Changes check ✅ Passed All changes are scoped to terminal rendering lifecycle management via occlusion control, directly addressing the workspace switching bug without introducing unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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-1789-terminal-blank-ws-v2

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 and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 23, 2026

Greptile Summary

This PR fixes stale blank/white terminal surfaces that appear after switching workspaces by driving Ghostty's native occlusion API (ghostty_surface_set_occlusion) from portal visibility transitions in GhosttySurfaceScrollView.setVisibleInUI, replacing the previous approach of ad-hoc surface refreshes.

Key changes:

  • New lastRequestedPortalOcclusionVisible: Bool? field in GhosttySurfaceScrollView deduplicates consecutive setOcclusion calls for the same visibility state, guarding against re-entrant or redundant transitions.
  • Occlusion wired to portal visibility — when wasVisible != visible and the requested occlusion state differs from the last requested value, terminalSurface?.setOcclusion(visible) is called so Ghostty can pause rendering on hide and queue a redraw on show.
  • Updated comment on GhosttyNSView.setVisibleInUI to reflect the new design intent, though the comment sits on the surface-view layer rather than on the scroll view where the behaviour is actually implemented (see inline comment).
  • The deduplication guard sets lastRequestedPortalOcclusionVisible before the optional-chained call, meaning a nil surface on first transition silently skips the occlusion update but the intent is recorded, which could suppress the call on a retry if the same visibility value is requested again (see inline comment).

Confidence Score: 5/5

  • Safe to merge — change is minimal, targeted, and correctly wires Ghostty's native occlusion lifecycle to portal visibility transitions.
  • The fix is a small, well-reasoned addition (one new field + four lines) with no new dependencies and no impact on other subsystems. Both inline comments are non-blocking P2 style suggestions; neither represents a bug on the normal workspace-switch path. The author's reasoning for skipping an automated test is sound given the AppKit rendering lifecycle nature of the bug.
  • No files require special attention.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds lastRequestedPortalOcclusionVisible deduplication guard and drives Ghostty occlusion from portal visibility transitions to fix blank terminal surfaces after workspace switches. Updated comment on GhosttyNSView.setVisibleInUI to reflect the new design intent.

Sequence Diagram

sequenceDiagram
    participant WS as Workspace.swift
    participant ScrollView as GhosttySurfaceScrollView
    participant NSView as GhosttyNSView
    participant Surface as TerminalSurface

    Note over WS,Surface: Workspace switch (hide)
    WS->>ScrollView: setVisibleInUI(false)
    ScrollView->>NSView: setVisibleInUI(false)
    NSView-->>NSView: visibleInUI = false
    ScrollView->>ScrollView: isHidden = true
    alt wasVisible != visible AND lastRequested != visible
        ScrollView->>ScrollView: lastRequestedPortalOcclusionVisible = false
        ScrollView->>Surface: setOcclusion(false)
        Surface-->>Surface: ghostty_surface_set_occlusion(false)<br/>(pause rendering)
    end

    Note over WS,Surface: Workspace switch (show)
    WS->>ScrollView: setVisibleInUI(true)
    ScrollView->>NSView: setVisibleInUI(true)
    NSView-->>NSView: visibleInUI = true
    ScrollView->>ScrollView: isHidden = false
    alt wasVisible != visible AND lastRequested != visible
        ScrollView->>ScrollView: lastRequestedPortalOcclusionVisible = true
        ScrollView->>Surface: setOcclusion(true)
        Surface-->>Surface: ghostty_surface_set_occlusion(true)<br/>(resume + queue redraw → fixes blank surface)
    end
Loading

Comments Outside Diff (1)

  1. Sources/GhosttyTerminalView.swift, line 4018-4024 (link)

    P2 Comment describes behavior implemented elsewhere

    The updated comment says "Explicit portal visibility transitions also drive Ghostty occlusion…", but GhosttyNSView.setVisibleInUI only sets the visibleInUI flag — the occlusion call (setOcclusion) is made by the caller, GhosttySurfaceScrollView.setVisibleInUI. A future reader scanning GhosttyNSView in isolation may expect to find the occlusion logic here.

    Consider tightening the comment to reflect what this function actually does, and moving the broader design note to GhosttySurfaceScrollView.setVisibleInUI where the behaviour is implemented:

Reviews (1): Last reviewed commit: "Fix blank terminal renders after workspa..." | Re-trigger Greptile

Comment on lines +7915 to +7918
if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
lastRequestedPortalOcclusionVisible = visible
surfaceView.terminalSurface?.setOcclusion(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.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/GhosttyTerminalView.swift`:
- 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).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef13150d-9b40-4831-baba-c885156bf236

📥 Commits

Reviewing files that changed from the base of the PR and between fd279bd and aa24f12.

📒 Files selected for processing (1)
  • Sources/GhosttyTerminalView.swift

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).

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/GhosttyTerminalView.swift">

<violation number="1" location="Sources/GhosttyTerminalView.swift:7915">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +7915 to +7917
if wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
lastRequestedPortalOcclusionVisible = visible
surfaceView.terminalSurface?.setOcclusion(visible)
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

@BH-M87
Copy link
Copy Markdown

BH-M87 commented Apr 7, 2026

Reproducing on the latest build. The original fix may have regressed, or this
is a closely related case.

  • cmux: 0.63.2 (build 79, commit 179b16c)
  • macOS: Sequoia 15.x
  • Hardware: MacBook Pro 14" (Nov 2024), Apple M4 Pro, 48 GB

Symptom: Switching between two already-existing workspaces via Shift+F12
leaves the terminal pane area completely blank. Sidebar and tab bar render
normally — only the terminal surface is empty. The blank state persists
indefinitely (does not auto-recover, keystrokes do not redraw it). The only
way to get content back is to switch to another workspace and switch back, at
which point the surface re-renders correctly.

Repro:

  1. Open cmux with ≥2 workspaces, each with an active shell containing
    scrollback content
  2. From workspace A, press Shift+F12 to jump to workspace B
  3. B's terminal area is blank and stays blank
  4. Press Shift+F12 to switch to C (or back to A), then Shift+F12 back to B →
    content reappears

Notes:

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.

Terminal surfaces go blank when switching between workspaces

2 participants