Fix terminal TUI background seam - #3903
Conversation
Claude Code paints explicit ANSI background cells for its input box and statusline. The regression captures the expected backdrop ownership boundary for solid opaque terminals before changing the production path. Constraint: Repository policy forbids local test execution; this commit is intentionally expected to fail before the fix. Confidence: high Scope-risk: narrow Directive: Keep opaque terminal backgrounds renderer-owned unless blur or translucency requires the host compositor. Tested: Not run locally per repository policy; regression is expected to be red before the fix. Not-tested: Local XCTest execution.
cmux was forcing Ghostty to leave every default terminal background transparent and then filling that area from the host window. That split default cells and explicit ANSI background cells across different compositor paths, which made Claude Code chrome show a visible seam. The policy now keeps solid opaque, unblurred terminal backgrounds inside Ghostty's renderer and reserves host-layer ownership for translucent or blurred terminal backgrounds where the macOS compositor is required. Constraint: background-opacity and background-blur still need host-layer ownership for compositor effects. Rejected: Tune the fallback theme palette | this would not remove the split renderer/host compositing path. Confidence: high Scope-risk: moderate Directive: Do not force macos-background-from-layer for opaque unblurred terminals; explicit ANSI cell backgrounds must share the renderer path with default cells. Tested: git diff --check Not-tested: Local XCTest/build execution per repository policy; CI pending.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR centralizes the decision logic for whether the terminal uses host-layer background by introducing opacity threshold handling and helper methods in ChangesTerminal Background Ownership Logic
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 14 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (14 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
The PR branch was behind origin/main after opening the PR. Merging main keeps CI evaluating the terminal background fix against the current hook and IME changes already accepted upstream. Constraint: iterate-pr requires syncing the PR branch with the base branch before CI iteration. Confidence: high Scope-risk: moderate Directive: Preserve the renderer-owned terminal background commits as the functional change; this merge only brings the branch current with main. Tested: Clean git merge from origin/main. Not-tested: Local build/test execution per repository policy.
Greptile SummaryThis PR fixes a visible background seam in TUI applications (e.g. Claude Code) running inside cmux terminals by routing solid opaque terminal backgrounds through Ghostty's renderer rather than the macOS compositor's host layer. Previously,
Confidence Score: 5/5Safe to merge; changes are scoped to terminal background rendering mode selection and have no impact on security, data, or correctness of other subsystems. The change correctly replaces an unconditional No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Load config files] --> B[defaultBackgroundOpacityValue]
A --> C[defaultBackgroundBlurValue]
B --> D{usesHostLayerBackground?}
C --> D
D -- "blur != .disabled" --> E[true: host-layer owned]
D -- "opacity < 0.999" --> E
D -- "opacity >= 0.999 && blur == .disabled" --> F[false: renderer-owned]
E --> G["macos-background-from-layer = true"]
F --> H["macos-background-from-layer = false"]
G --> I[setUsesHostLayerBackground true]
H --> J[setUsesHostLayerBackground false]
I --> K[terminalRenderingMode: .windowHostBackdrop]
J --> L[terminalRenderingMode: .ghosttyRendererOwnedBackgroundImage]
Reviews (2): Last reviewed commit: "Document terminal background ownership t..." | Re-trigger Greptile |
| if backgroundBlur != .disabled { | ||
| return true | ||
| } | ||
| return clampedOpacity(backgroundOpacity) < 0.999 |
There was a problem hiding this comment.
The
0.999 threshold used to classify a terminal as "opaque" is a magic constant with no explanation. Without a comment, a future reader won't know whether this is meant to absorb floating-point round-trip imprecision (e.g. a user-typed 1.0 that comes back as 0.9999… after a Double → CGFloat → Double cycle), or whether values like 0.999 are intentionally treated as fully opaque. Adding a named constant or an inline comment clarifies the intent.
| return clampedOpacity(backgroundOpacity) < 0.999 | |
| // Treat opacity values within floating-point round-trip tolerance of 1.0 as | |
| // fully opaque, so a user-configured value of 1.0 that loses a ULP through | |
| // Double → CGFloat → Double conversion never accidentally triggers host-layer | |
| // ownership. | |
| let opaqueThreshold: CGFloat = 0.999 | |
| return clampedOpacity(backgroundOpacity) < opaqueThreshold |
There was a problem hiding this comment.
Fixed by naming and documenting the opaque background ownership threshold so the 0.999 tolerance is explicit.
— Claude Code
| func testOpaqueUnblurredTerminalBackgroundIsRendererOwned() { | ||
| let snapshot = makeSnapshot( | ||
| unifySurfaceBackdrops: false, | ||
| backgroundOpacity: 1.0, | ||
| backgroundBlur: .disabled | ||
| ) | ||
| let policy = snapshot.policy(for: .windowRoot) | ||
|
|
||
| XCTAssertNil(policy.hostLayerBackgroundColor) | ||
| guard case let .ghosttyTerminalBackdrop(_, _, renderingMode) = policy else { | ||
| XCTFail("expected terminal backdrop policy") | ||
| return | ||
| } | ||
| XCTAssertEqual(renderingMode, .ghosttyRendererOwnedBackgroundImage) | ||
| } |
There was a problem hiding this comment.
Missing complementary regression assertions
The new test verifies the opaque+unblurred → renderer-owned direction, but the inverse paths — translucent opacity → host-layer, and backgroundBlur != .disabled → host-layer — are not asserted. Without those, a future refactor of usesHostLayerBackground that accidentally widened the renderer-owned path (e.g., removing the blur check) would not be caught by CI. Consider adding at least one translucent case and one blur-enabled case that assert renderingMode == .windowHostBackdrop.
There was a problem hiding this comment.
Fixed by adding inverse regression assertions for translucent and blurred terminal backgrounds staying host-layer-owned.
— Claude Code
Greptile correctly flagged that the opacity threshold needed intent and that the host-layer inverse paths needed coverage. Naming the threshold and adding translucent plus blurred assertions keeps the renderer-owned path narrow and understandable. Constraint: Review feedback was low-priority but directly improved the regression boundary. Confidence: high Scope-risk: narrow Directive: Keep host-layer ownership covered for translucent and blurred backgrounds when adjusting terminal rendering policy. Tested: git diff --check Not-tested: Local XCTest execution per repository policy; CI pending.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Sources/GhosttyTerminalView.swift (1)
2101-2117:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftSurface-scoped opacity/blur changes can still use the wrong compositor path.
setUsesHostLayerBackground(...)is now only driven during app/fallback config loading. Later surface-scoped config updates still flow throughupdateDefaultBackground(...), but they do not recompute this flag, so a surface that becomes translucent or blurred after startup can keep the startup ownership decision and render through the wrong path. That breaks the invariant this PR is trying to preserve for translucent/blurred terminals. Based on learnings: keep “surface-scoped” Ghostty config reloads strictly scoped to the target surface.Also applies to: 2252-2262
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Sources/GhosttyTerminalView.swift` around lines 2101 - 2117, The surface-scoped Ghostty config reloads are not recomputing the compositor path flag, so surfaces that change translucency/blur after startup keep the startup ownership decision; to fix, ensure any surface-scoped update (e.g., within updateDefaultBackground(...)) recomputes the flag by calling usesHostLayerBackground(for: targetConfig) and then invoking setUsesHostLayerBackground(theComputedFlag, source: "updateDefaultBackground.surface") for that surface only; keep loadInlineGhosttyConfig and loadCmuxOwnedGhosttyKeybindOverrides scoped to the target surface and avoid relying solely on the app/fallback initialization path (the initialization calls around usesHostLayerBackground(for: fallbackConfig) remain but do not substitute for per-surface recomputation).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@Sources/GhosttyTerminalView.swift`:
- Around line 2101-2117: The surface-scoped Ghostty config reloads are not
recomputing the compositor path flag, so surfaces that change translucency/blur
after startup keep the startup ownership decision; to fix, ensure any
surface-scoped update (e.g., within updateDefaultBackground(...)) recomputes the
flag by calling usesHostLayerBackground(for: targetConfig) and then invoking
setUsesHostLayerBackground(theComputedFlag, source:
"updateDefaultBackground.surface") for that surface only; keep
loadInlineGhosttyConfig and loadCmuxOwnedGhosttyKeybindOverrides scoped to the
target surface and avoid relying solely on the app/fallback initialization path
(the initialization calls around usesHostLayerBackground(for: fallbackConfig)
remain but do not substitute for per-surface recomputation).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 779085b5-e913-4302-af89-11611ef43e70
📒 Files selected for processing (3)
Sources/GhosttyTerminalView.swiftSources/Windowing/WindowAppearanceSnapshot.swiftcmuxTests/WindowAppearanceSnapshotTests.swift
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 75b2ece. Configure here.
| backgroundBlur: defaultBackgroundBlurValue(from: config) | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
Runtime config changes leave rendering mode stale
Medium Severity
The GHOSTTY_ACTION_CONFIG_CHANGE handler calls updateDefaultBackground (which updates stored opacity/blur) but never recalculates usesHostLayerBackground. Previously this was safe because usesHostLayerBackground was always true. Now that the initialization paths dynamically set it to false for opaque unblurred backgrounds, a runtime config change that transitions opacity from opaque to translucent leaves the flag stale at false, causing WindowAppearanceSnapshot.current(...) to produce a snapshot with terminalRenderingMode = .ghosttyRendererOwnedBackgroundImage when it needs .windowHostBackdrop.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 75b2ece. Configure here.


Summary
macos-background-from-layerfor every terminal.Repro
./scripts/reload.sh --tag issue-3655-terminal-bg-tui-seam --launch.claude./Applications/cmux.app/Contents/Resources/bin/claude./tmp/cmux-claude-window.pngand/tmp/ghostty-claude-typed.png.Observed: Claude Code's input/statusline chrome in cmux rendered over a visibly different backdrop than surrounding terminal scrollback. The same TUI in standalone Ghostty blended with the surrounding terminal background.
Expected: solid opaque terminal backgrounds should be composited through the same renderer path as explicit ANSI cell backgrounds so Claude Code chrome blends into the scrollback.
Commit Structure
e60b06251adds the failing regression test only.caa37c5dcapplies the renderer-owned background fix.Fixes #3655
Verification
git diff --checkNot run locally: XCTest/build execution per repository policy; CI will run on the PR.
Note
Medium Risk
Changes terminal backdrop ownership logic and config injection based on opacity/blur, which can affect rendering/compositing across macOS window surfaces. Regression risk is mostly visual (seams, transparency/blur behavior) rather than security or data correctness.
Overview
Fixes terminal background seams by no longer forcing
macos-background-from-layer = truefor all terminals; instead,GhosttyTerminalViewderivesusesHostLayerBackgroundfrombackground-opacityandbackground-blur(including fallback config) and injects the matchingmacos-background-from-layervalue.WindowAppearanceSnapshotadds a sharedusesHostLayerBackground(backgroundOpacity:backgroundBlur:)helper (with an opacity threshold to avoid float round-trip issues) and new tests assert that opaque, unblurred terminals are renderer-owned while translucent or blurred terminals remain host-layer owned.Reviewed by Cursor Bugbot for commit 75b2ece. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Fixes a visible seam in TUIs by keeping solid, opaque terminal backgrounds renderer-owned. We now only use the macOS host layer for blurred or translucent terminals, clarified the opacity threshold to avoid float rounding issues, and synced with
main.usesHostLayerBackgroundfrombackground-opacityandbackground-blur(including fallback config); stop forcingmacos-background-from-layer = true.WindowAppearanceSnapshot(usesHostLayerBackground,terminalRenderingMode).Written for commit 75b2ece. Summary will update on new commits.
Summary by CodeRabbit