Skip to content

fix: terminal content duplication on window resize (revives #4765) - #6386

Merged
austinywang merged 23 commits into
manaflow-ai:mainfrom
mvanhorn:fix/terminal-resize-duplication
Jun 19, 2026
Merged

austinywang merged 23 commits into
manaflow-ai:mainfrom
mvanhorn:fix/terminal-resize-duplication

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 18, 2026 •

Copy link
Copy Markdown
Contributor

Revives @austinpower1258's stalled fix in #4765, rebased onto current main with the merge conflict resolved. Closes #3052.

The bug

Drag-resizing the window duplicates/overlaps terminal content. This is not the vendored ghostty fork's emulator reflow — that's already fixed (the fork is 1.3.2-dev, past upstream's #10258). It's in the embedding layer: ghostty's single set_size API drives both renderer pixels and PTY geometry, so sub-cell pixel churn during an AppKit live-resize drag (1 point = 2 Retina px) fires a SIGWINCH storm and the child app repaints over itself.

The fix (@austinpower1258's approach, preserved)

Coalesce pixel-only resizes: only forward ghostty_surface_set_size when the change actually alters the terminal's column/row grid, and flush the exact final size on viewDidEndLiveResize. Coalescing is gated strictly to the live window-resize phase, so programmatic / split / SSH-driven resizes always apply immediately.

What this PR adds over #4765

  • Rebased onto current main (the original PR was conflicting + red).
  • Conflict resolution: main added manual-I/O (tmux mirror) reflow suppression in the same if sizeChanged block. Resolved by running the coalescing guard first, so a coalesced (skipped) resize never leaves a manual-I/O pane with DECAWM disabled — then the DECAWM-suppressed apply. This is directly relevant to the reported cmux ssh resize breakage.

Original implementation and tests by @austinpower1258 (commits preserved in this branch's history).

Verification

  • xcodebuild ... -derivedDataPath ... build (CMUX_SKIP_ZIG_BUILD=1): BUILD SUCCEEDED, 0 errors.
  • TerminalSurfaceResizePolicyTests wired (pbxproj test-wiring lint passes); CI runs the suite.
  • Pending: live GUI repro (drag-resize a busy pane, cmux ssh, tmux reflow) on a maintainer machine.

Planning + conflict resolution assisted by Claude Code.


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


Summary by cubic

Fixes terminal content duplication on live window resize by skipping same‑grid pixel-only size changes, then applying the exact final size after the drag. Non‑live resizes and backing‑scale changes apply immediately.

  • Bug Fixes
    • Coalesce pixel-only resizes only when the grid won’t change; conservative checks handle grow/shrink, ambiguous remainders, and terminal padding. Never coalesce if the backing scale changes.
    • Run the coalescing guard before manual‑I/O (tmux mirror) reflow suppression so a skipped resize can’t leave DECAWM off.
    • On viewDidEndLiveResize, bypass coalescing to apply the final size and invalidate text input coordinates.
    • Expose TerminalSurface.shouldApplySurfacePixelSizeChange and add TerminalSurfaceResizePolicyTests; update test wiring and the file‑length budget.

Written for commit 36d5d33. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Improvements
    • Added a new resize policy to coalesce pixel-only size changes when they won’t impact the terminal grid, reducing unnecessary grid recalculations.
    • Updated resizing behavior to reconcile surface sizing right after a window live-resize ends, with an option to bypass pixel-only coalescing to ensure timely updates.
  • Tests
    • Added unit tests validating pixel-only coalescing, conservative handling of ambiguous remainder cases, and correct behavior based on grid changes and live-resize timing.

austinywang and others added 14 commits May 25, 2026 19:08
…esizes

Revives @austinpower1258's stalled fix for terminal content duplication
on window resize (manaflow-ai#3052), rebased onto current main.

Root cause is cmux's embedding layer, not the vendored ghostty fork
(1.3.2-dev already carries the upstream emulator reflow fix manaflow-ai#10258):
ghostty's single set_size API drives both renderer pixels and PTY
geometry, so sub-cell pixel churn during an AppKit live-resize drag
fires a SIGWINCH storm and the child app repaints over itself.

The fix coalesces pixel-only resizes (only forward set_size when the
grid's columns/rows actually change) and flushes the exact final size on
viewDidEndLiveResize. Coalescing is gated strictly to the live
window-resize phase so programmatic/SSH/split resizes always apply.

Conflict resolution: main added manual-I/O (tmux mirror) reflow
suppression in the same `if sizeChanged` block. Resolved by running the
coalescing guard FIRST, so a coalesced (skipped) resize never leaves a
manual-I/O pane with DECAWM disabled, then the DECAWM-suppressed apply.

Original implementation by @austinpower1258 in manaflow-ai#4765.

Co-Authored-By: austinpower1258 <austinwang115@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyBXh77E4z96v18JS77oaN
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown

@mvanhorn is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a shouldApplySurfacePixelSizeChange static policy function to TerminalSurface that determines whether a pixel-only window resize would alter the terminal grid. Integrates this into updateSize via a new coalescePixelOnlyResize parameter, hooks the coalescing path into GhosttyNSView's live-resize lifecycle, and covers the policy with a new TerminalSurfaceResizePolicyTests suite.

Changes

Pixel-only resize coalescing

Layer / File(s) Summary
Grid-alignment policy function and updateSize gate
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift
Adds the public static shouldApplySurfacePixelSizeChange that computes raw and remainder-adjusted target column/row counts, returning true only when the grid would change. Extends updateSize with coalescePixelOnlyResize: Bool = false and inserts an early-return gate that queries current Ghostty surface metrics and calls the helper to skip grid-inert resizes.
Live-resize lifecycle and deferral wiring
Sources/GhosttyTerminalView.swift
Adds viewDidEndLiveResize() to GhosttyNSView to force a final pixel reconciliation and invalidate text input. Introduces isWindowLiveResizeActive and updates activeSurfaceResizeDeferralReason() to skip deferral during live resize. Extends updateSurfaceSize with bypassLiveResizeCoalescing and propagates coalescePixelOnlyResize into terminalSurface.updateSize.
Policy unit tests and Xcode project wiring
cmuxTests/TerminalSurfaceResizePolicyTests.swift, cmux.xcodeproj/project.pbxproj
Adds TerminalSurfaceResizePolicyTests with three tests covering intra-grid coalescing, ambiguous-remainder conservatism, and conditional application based on coalescePixelOnlyResize/hasAppliedPixelSize combinations. Wires the test file into the cmuxTests target via PBXBuildFile, PBXFileReference, PBXGroup, and PBXSourcesBuildPhase entries.

Sequence Diagram

sequenceDiagram
  participant GhosttyNSView
  participant updateSurfaceSize
  participant TerminalSurface.updateSize
  participant shouldApplySurfacePixelSizeChange
  participant GhosttyTerminal

  GhosttyNSView->>GhosttyNSView: viewDidEndLiveResize()
  GhosttyNSView->>updateSurfaceSize: bypassLiveResizeCoalescing=true
  updateSurfaceSize->>TerminalSurface.updateSize: coalescePixelOnlyResize=(isWindowLiveResizeActive && !bypassLiveResizeCoalescing)
  TerminalSurface.updateSize->>GhosttyTerminal: query current surface metrics
  TerminalSurface.updateSize->>shouldApplySurfacePixelSizeChange: current/target pixel dimensions
  shouldApplySurfacePixelSizeChange-->>TerminalSurface.updateSize: false (pixel-only, no grid change)
  TerminalSurface.updateSize-->>updateSurfaceSize: return early with scaleChanged only
  updateSurfaceSize->>GhosttyNSView: invalidateTextInputCoordinates()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 Hop, hop — the window stretches wide,
But pixels that don't shift the grid? They hide!
My shouldApply checks each cell with care,
No phantom columns spawned from thin air.
The terminal reflows, content stays true —
A coalesced resize, tidy and new! 🌿

🚥 Pre-merge checks | ✅ 21 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (21 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: terminal content duplication on window resize (revives #4765)' clearly identifies the main issue being fixed and references the original work, summarizing the primary change accurately.
Linked Issues check ✅ Passed The changes directly address issue #3052 by implementing pixel-only resize coalescing to prevent terminal content duplication during window drag operations. Test coverage validates the coalescing logic, and the fix gates this behavior strictly to live-resize phases.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the pixel-only resize coalescing fix: TerminalSurface sizing helpers, GhosttyNSView live-resize handling, test suite addition, and project wiring. No unrelated modifications detected.
Cmux Swift Actor Isolation ✅ Passed No Swift 6 actor isolation violations: new static utility is pure/isolated, methods maintain @MainActor/implicit-MainActor contracts, and parameters are immutable values.
Cmux Swift Blocking Runtime ✅ Passed No blocking or timing-based synchronization patterns detected in production code; new sizing logic uses pure arithmetic and state-based decisions with no semaphores, waits, sleeps, locks, or polling.
Cmux Expensive Synchronous Load ✅ Passed PR adds window resize coalescing logic with pure arithmetic calculations and C API getter calls; no expensive synchronous loaders like RestorableAgentSessionIndex.load() are introduced onto main ac...
Cmux Cache Substitution Correctness ✅ Passed No cache substitution issue found. The PR uses transient session-state variables (lastPixelWidth, lastPixelHeight) to track pixel sizes during resize, not persisted cache. All authoritative inputs...
Cmux No Hacky Sleeps ✅ Passed PR modifies only Swift and Xcode project files. The runtime-no-hacky-sleeps rule applies exclusively to TypeScript, JavaScript, shell, and non-Swift build/runtime scripts; Swift is covered by a sep...
Cmux Algorithmic Complexity ✅ Passed New code performs O(1) arithmetic on fixed scalar pixel/grid dimensions with no loops, collections, or rescans. Test scaffolding is exempt. Meets algorithmic complexity rules.
Cmux Swift Concurrency ✅ Passed PR introduces no new legacy async patterns: shouldApplySurfacePixelSizeChange is a pure function, viewDidEndLiveResize is synchronous, and test suite uses modern Testing framework without problemat...
Cmux Swift @Concurrent ✅ Passed All changes are purely synchronous with no async functions, @concurrent annotations, or concurrency isolation violations. The existing @MainActor annotation on updateSize is appropriate for UI-boun...
Cmux Swift File And Package Boundaries ✅ Passed PR fixes terminal resize duplication (#3052) with focused changes. TerminalSurface+Sizing.swift remains 492 lines (below 500-line tracking threshold), is in a SwiftPM package with coherent single r...
Cmux Swiftpm Lockfiles ✅ Passed PR makes no SwiftPM dependency changes, no .gitignore modifications, no Package.resolved updates, and no XCRemoteSwiftPackageReference changes; project.pbxproj edits are test-file wiring only.
Cmux Swift Logging ✅ Passed All logging in the PR complies with swift-logging.md rules: debug logging via Self.sizeLog() and cmuxDebugLog() are properly guarded by #if DEBUG blocks; the new test suite has no logging; no print...
Cmux User-Facing Error Privacy ✅ Passed PR adds pixel-only resize coalescing logic with no user-facing errors, alerts, or sensitive data exposed. All debug logs are gated by #if DEBUG and write to temporary files, not shown to users.
Cmux Full Internationalization ✅ Passed PR adds only internal sizing/protocol code and tests with debug logs. No user-facing text, alerts, UI strings, Info.plist changes, or string catalog modifications requiring localization.
Cmux Swiftui State Layout ✅ Passed PR modifies AppKit NSView subclass (GhosttyNSView) and model-layer code (TerminalSurface extensions) with no new SwiftUI state decorators (@Published, @Observable, @StateObject, etc.). All changes...
Cmux Architecture Rethink ✅ Passed PR introduces a small correctness fix for resize duplication using pure computation and AppKit lifecycle callbacks. No timing delays, new mutable flags, observers, side channels, or bad state repre...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new standalone windows (NSWindow, NSPanel, NSWindowController, SwiftUI Window/WindowGroup) created. Changes are limited to sizing logic and NSView lifecycle methods in existing terminal pane inf...
Cmux Source Artifacts ✅ Passed All modified files are hand-written source code, tests, or standard Xcode project configuration; no artifacts, generated files, caches, build output, or forbidden directories are introduced.
Description check ✅ Passed PR description covers the bug, fix approach, key additions over the original PR, and verification status with clear context.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

greptile-apps Bot commented Jun 18, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR coalesces sub-cell pixel resizes during AppKit live-resize drags so that only size changes that actually alter the terminal's column/row grid are forwarded to ghostty_surface_set_size, eliminating the SIGWINCH storm that caused terminal content duplication. The exact final size is always flushed on viewDidEndLiveResize, and coalescing is never applied on scale changes or outside the live-resize window.

  • Adds TerminalSurface.shouldApplySurfacePixelSizeChange — a pure static function with conservative interval arithmetic that decides whether a pixel-only resize crosses a cell boundary — and wires it into updateSize behind a coalescePixelOnlyResize flag that is true only while isWindowLiveResizeActive.
  • Adds viewDidEndLiveResize to GhosttyNSView, which calls updateSurfaceSize(bypassLiveResizeCoalescing: true) to guarantee the final geometry is applied, and extracts isWindowLiveResizeActive to deduplicate the two existing uses of inLiveResize || window?.inLiveResize == true.
  • The coalescing guard runs before the manual-I/O (tmux mirror) DECAWM toggle, preserving the invariant that a skipped resize never leaves a pane with DECAWM disabled; unit tests for the policy function are wired into the cmuxTests target.

Confidence Score: 5/5

Safe to merge; the coalescing guard is correctly bounded to the AppKit live-resize window and the final-size flush in viewDidEndLiveResize is unconditional.

The mayChangeGrid interval arithmetic is correct and well-covered by unit tests. The scale-change bypass (coalescePixelOnlyResize && !scaleChanged) ensures Retina/display-switch resizes are never suppressed. Coalesced skips deliberately leave lastPixelWidth/lastPixelHeight unchanged so the viewDidEndLiveResize flush always sees sizeChanged = true and applies the exact final geometry. The DECAWM-ordering invariant is preserved. No actor-isolation regressions, no blocking primitives introduced.

No files require special attention.

Important Files Changed

Filename Overview
Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Sizing.swift Adds the coalescing policy function and wires it into updateSize; the interval arithmetic in mayChangeGrid is correct, the scale-change bypass is in place, and the DECAWM ordering invariant is preserved.
Sources/GhosttyTerminalView.swift Adds viewDidEndLiveResize for the final-size flush, extracts isWindowLiveResizeActive, and threads bypassLiveResizeCoalescing into updateSurfaceSize — a clean, focused AppKit lifecycle integration with no actor-isolation or blocking-primitive concerns.
cmuxTests/TerminalSurfaceResizePolicyTests.swift New Swift Testing suite covering same-grid coalescing, conservative shrink/grow boundary cases, full-cell remainder cases, and the coalescePixelOnlyResize: false / hasAppliedPixelSize: false bypass paths.
cmux.xcodeproj/project.pbxproj Adds TerminalSurfaceResizePolicyTests.swift to the cmuxTests target with consistent ID-generation style already used in the project; no other structural changes.
.github/swift-file-length-budget.tsv Updates line-count budget for GhosttyTerminalView.swift (+14 lines for the new override and extracted property) and reflects a pre-existing reduction in PaneMemoryGuardrail.swift that entered via the rebase.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant AppKit
    participant GhosttyNSView
    participant TerminalSurface
    participant Ghostty as ghostty_surface_*

    Note over AppKit,Ghostty: Live window drag
    loop Each layout() call during drag
        AppKit->>GhosttyNSView: layout()
        GhosttyNSView->>GhosttyNSView: updateSurfaceSize(bypassLiveResizeCoalescing: false)
        GhosttyNSView->>TerminalSurface: updateSize(coalescePixelOnlyResize: true)
        TerminalSurface->>Ghostty: ghostty_surface_size() — read current grid
        TerminalSurface->>TerminalSurface: shouldApplySurfacePixelSizeChange()
        alt Same-grid pixel churn (coalesced)
            TerminalSurface-->>GhosttyNSView: return false (skipped, lastPixelWidth unchanged)
        else Grid boundary crossed
            TerminalSurface->>Ghostty: ghostty_surface_set_size(wpx, hpx)
            TerminalSurface-->>GhosttyNSView: return true
        end
    end

    Note over AppKit,Ghostty: User releases mouse
    AppKit->>GhosttyNSView: viewDidEndLiveResize()
    GhosttyNSView->>GhosttyNSView: updateSurfaceSize(bypassLiveResizeCoalescing: true)
    GhosttyNSView->>TerminalSurface: updateSize(coalescePixelOnlyResize: false)
    TerminalSurface->>Ghostty: ghostty_surface_set_size(final wpx, final hpx)
    GhosttyNSView->>GhosttyNSView: invalidateTextInputCoordinates()

    Note over AppKit,Ghostty: Post-resize layout
    AppKit->>GhosttyNSView: layout()
    GhosttyNSView->>TerminalSurface: updateSize(coalescePixelOnlyResize: false)
    Note right of TerminalSurface: sizeChanged=false (already applied),<br/>nothing forwarded
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant AppKit
    participant GhosttyNSView
    participant TerminalSurface
    participant Ghostty as ghostty_surface_*

    Note over AppKit,Ghostty: Live window drag
    loop Each layout() call during drag
        AppKit->>GhosttyNSView: layout()
        GhosttyNSView->>GhosttyNSView: updateSurfaceSize(bypassLiveResizeCoalescing: false)
        GhosttyNSView->>TerminalSurface: updateSize(coalescePixelOnlyResize: true)
        TerminalSurface->>Ghostty: ghostty_surface_size() — read current grid
        TerminalSurface->>TerminalSurface: shouldApplySurfacePixelSizeChange()
        alt Same-grid pixel churn (coalesced)
            TerminalSurface-->>GhosttyNSView: return false (skipped, lastPixelWidth unchanged)
        else Grid boundary crossed
            TerminalSurface->>Ghostty: ghostty_surface_set_size(wpx, hpx)
            TerminalSurface-->>GhosttyNSView: return true
        end
    end

    Note over AppKit,Ghostty: User releases mouse
    AppKit->>GhosttyNSView: viewDidEndLiveResize()
    GhosttyNSView->>GhosttyNSView: updateSurfaceSize(bypassLiveResizeCoalescing: true)
    GhosttyNSView->>TerminalSurface: updateSize(coalescePixelOnlyResize: false)
    TerminalSurface->>Ghostty: ghostty_surface_set_size(final wpx, final hpx)
    GhosttyNSView->>GhosttyNSView: invalidateTextInputCoordinates()

    Note over AppKit,Ghostty: Post-resize layout
    AppKit->>GhosttyNSView: layout()
    GhosttyNSView->>TerminalSurface: updateSize(coalescePixelOnlyResize: false)
    Note right of TerminalSurface: sizeChanged=false (already applied),<br/>nothing forwarded
Loading

Reviews (9): Last reviewed commit: "fix: keep terminal resize coalescing con..." | Re-trigger Greptile

Comment thread Sources/GhosttyTerminalView.swift
@vercel

vercel Bot commented Jun 18, 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 Jun 19, 2026 9:14pm

@austinywang
austinywang merged commit be35bd4 into manaflow-ai:main Jun 19, 2026
24 checks passed
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks for landing this, @austinywang. Reviving the stalled resize fix and clearing the terminal content duplication is great to see merged.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thank you for reviving this, @austinywang. Resolving the terminal content duplication on window resize is a fix a lot of people will feel.

azooz2003-bit added a commit that referenced this pull request Jul 4, 2026
…n HEAD's resize model)

main's #7318 viewDidEndLiveResize called updateSurfaceSize(bypassLiveResizeCoalescing:),
but HEAD's refactored updateSurfaceSize lacks the coalescePixelOnlyResize model (#6386);
HEAD applies resizes continuously during live resize. Call HEAD's updateSurfaceSize()
(kept the new viewDidEndLiveResize + resetCursorRects cursor-shape). #6386 divergence
recorded in merge-deferred-gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 36d5d332 Deployed Jun 19, 2026 by vercel[bot]
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.

Resizing the window duplicates terminal contents many times

2 participants