Skip to content

Fix tmux resize/layout redraws in embedded terminal surfaces#3120

Open
austinywang wants to merge 2 commits intomainfrom
issue-3118-tmux-resize-layout
Open

Fix tmux resize/layout redraws in embedded terminal surfaces#3120
austinywang wants to merge 2 commits intomainfrom
issue-3118-tmux-resize-layout

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Apr 22, 2026

Summary

  • add a tmux regression that round-trips layout and resize key sequences back to the same visual state and diffs panel snapshots
  • reject wrong-sized IOSurface contents during resize churn and pin old contents to the top-left instead of stretching
  • nudge Ghostty to refresh immediately after real surface size changes and tmux-style non-text command keys

Testing

  • not run locally (per repo policy)

Fixes #3118


Note

Medium Risk
Changes Metal layer content handling and forces extra ghostty_surface_refresh/forceRefresh calls during resize and certain key events, which could affect rendering correctness and performance in the embedded terminal path.

Overview
Fixes tmux pane resize/layout redraw glitches in embedded terminal surfaces by forcing immediate Ghostty refreshes after real pixel-size changes and after non-text command/navigation key events (with a small time-based throttle).

Hardens the backing CAMetalLayer by switching to a custom GhosttyMetalLayer that rejects wrong-sized IOSurface contents updates during resize churn and pins prior contents to .topLeft to avoid stretch artifacts.

Adds an end-to-end regression test (test_issue_3118_tmux_resize_layout.py) that drives tmux layout/resize round-trips and asserts visual stability by diffing captured panel PNGs.

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

Summary by CodeRabbit

  • Bug Fixes

    • Fixed display content alignment and refresh issues when resizing terminal windows
    • Improved keyboard responsiveness by enhancing refresh behavior for command and navigation keys
  • Tests

    • Added regression test suite for tmux resize and layout operations to ensure visual stability

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 22, 2026 9:28pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

This PR fixes tmux pane resize and layout rendering errors in cmux by introducing a custom Metal layer that validates and prevents stale IOSurface display, triggering immediate surface refresh on resize operations, and adding key-event-driven refresh behavior. A regression test validates pixel-level visual stability across tmux layout transformations.

Changes

Cohort / File(s) Summary
Terminal Surface Rendering
Sources/GhosttyTerminalView.swift
Implements GhosttyMetalLayer to intercept and reject IOSurface assignments with mismatched dimensions; modifies TerminalSurface.updateSize to immediately call ghostty_surface_refresh after resize/scale changes; switches GhosttyNSView backing layer from CAMetalLayer to GhosttyMetalLayer with contentsGravity = .topLeft; adds command-key-driven refresh triggering after navigation/editing keycodes and control/option-modified events.
Regression Test
tests/test_issue_3118_tmux_resize_layout.py
New test script that provisions a cmux surface, launches tmux with deterministic output, applies and reverts layout/resize sequences, captures PNG snapshots, parses RGBA pixel data, and enforces thresholds to validate that redraws occur during changes and that round-trip reverts maintain visual fidelity within noise tolerance.

Sequence Diagram(s)

sequenceDiagram
    participant View as GhosttyNSView
    participant Layer as GhosttyMetalLayer
    participant Surface as TerminalSurface
    participant Ghostty as Ghostty Engine
    
    rect rgba(100, 200, 100, 0.5)
    Note over View,Ghostty: Resize/Scale Path
    View->>Surface: updateSize(newSize)
    Surface->>Ghostty: ghostty_surface_refresh()
    activate Ghostty
    Ghostty->>Ghostty: Render frame to IOSurface
    deactivate Ghostty
    Ghostty-->>Layer: IOSurface contents
    Layer->>Layer: Validate dimensions match scaled bounds
    alt Dimensions valid
        Layer->>View: Accept IOSurface
    else Dimensions mismatch
        Layer->>View: Reject (prevent stale content)
    end
    end
    
    rect rgba(100, 150, 200, 0.5)
    Note over View,Surface: Key Event Path
    View->>View: keyDown event (cmd/nav/edit)
    View->>Surface: forceRefresh()
    Surface->>Ghostty: ghostty_surface_refresh()
    activate Ghostty
    Ghostty->>Layer: Updated IOSurface
    deactivate Ghostty
    Layer->>View: Render validated content
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 Resize no more brings render strife,
A Metal layer validates each life,
Ghostty's touch now fresh and keen,
When tmux panes split the screen—
tmux stays crisp, no stale in sight! 🎉

🚥 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 accurately summarizes the main change: fixing tmux resize/layout redraws in embedded terminal surfaces, which is the core objective of the PR.
Description check ✅ Passed The description covers summary of changes and includes issue reference, but lacks explicit testing methodology, demo video, and incomplete checklist items.
Linked Issues check ✅ Passed The PR successfully addresses issue #3118 by implementing three key fixes: custom Metal layer to reject mismatched IOSurfaces, immediate Ghostty refresh triggers, and a regression test validating tmux resize/layout visual stability.
Out of Scope Changes check ✅ Passed All changes are directly scoped to resolving the tmux resize/layout rendering issue: the Metal layer fix, refresh triggers, and regression test are all necessary components of the solution.

✏️ 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-3118-tmux-resize-layout

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 Apr 22, 2026

Greptile Summary

This PR addresses tmux resize/layout visual stale-state bugs in the embedded terminal by: (1) overriding CALayer.contents on GhosttyMetalLayer to reject wrong-sized IOSurfaces during resize churn and pinning old contents to topLeft instead of stretching, (2) calling ghostty_surface_refresh after size/scale changes to prevent stale pixels, and (3) adding a command-key refresh nudge for tmux-style non-text keys. A new regression test validates round-trip pixel stability via a hand-rolled PNG decoder.

  • P1: shouldAccept reads self.bounds and self.contentsScale inside the contents setter without synchronization; Core Animation invokes this setter on the render thread during drawable presentation, creating a data race with main-thread resize writes.
  • P2: ghostty_surface_refresh is now called on scale-only changes (e.g., moving between Retina/non-Retina displays) in addition to size changes — the intent per the comment is resize-only.

Confidence Score: 3/5

Needs the thread-safety fix in shouldAccept before merging — data races under Swift's memory model can cause crashes in production.

The P1 data race in the contents setter override (shouldAccept reading CALayer model properties off-main-thread) is a genuine correctness issue that can surface under concurrent resize activity on any build, not just debug. It keeps the score below 4.

Sources/GhosttyTerminalView.swift — specifically the shouldAccept method and the placement of ghostty_surface_refresh.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds IOSurface size validation via CALayer.contents override and a post-resize ghostty_surface_refresh nudge. The shouldAccept helper reads bounds/contentsScale without synchronization from within the setter, which Core Animation can invoke on the render thread — a potential data race. The unconditional ghostty_surface_refresh also fires on scale-only changes, widening its scope beyond the stated tmux resize intent.
tests/test_issue_3118_tmux_resize_layout.py New regression test that launches tmux inside a cmux terminal surface, applies layout/resize key sequences, and pixel-diffs panel snapshots to verify round-trip visual stability. Includes a clean hand-rolled PNG decoder, a solid noise-floor baseline, and well-scoped tolerances. Logic and structure look correct.

Sequence Diagram

sequenceDiagram
    participant Main as Main Thread
    participant Render as Render/CA Thread
    participant GML as GhosttyMetalLayer
    participant Ghost as Ghostty Surface

    Main->>GML: makeBackingLayer() → GhosttyMetalLayer (contentsGravity=.topLeft)
    Main->>Ghost: updateSize() → ghostty_surface_set_size()
    Main->>Ghost: ghostty_surface_refresh(surface) [NEW]
    Render->>GML: nextDrawable() [lock acquired]
    Render->>Ghost: render frame into IOSurface
    Render->>GML: contents = newIOSurface [NEW override]
    GML->>GML: shouldAccept(): reads self.bounds ⚠️ (no lock)
    alt size matches (±1px)
        GML->>GML: super.contents = newIOSurface
    else size mismatch (stale resize frame)
        GML->>GML: drop — return without setting
    end
    Note over Main,Ghost: keyDown ctrlCommand or tmux key
    Main->>Main: refreshSurfaceAfterCommandIfNeeded() [NEW]
    Main->>Ghost: forceRefresh(reason:) [rate-limited 50ms]
Loading

Reviews (1): Last reviewed commit: "Fix tmux resize/layout redraws in termin..." | Re-trigger Greptile

Comment on lines +3587 to +3602
private func shouldAccept(contents newValue: Any?) -> Bool {
guard let newValue else { return true }
guard let obj = newValue as AnyObject? else { return true }
let cf = obj as CFTypeRef
guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return true }

let surfaceRef = obj as! IOSurfaceRef
let scale = max(contentsScale, 1.0)
let expectedWidth = Int((bounds.width * scale).rounded(.toNearestOrAwayFromZero))
let expectedHeight = Int((bounds.height * scale).rounded(.toNearestOrAwayFromZero))
let actualWidth = Int(IOSurfaceGetWidth(surfaceRef))
let actualHeight = Int(IOSurfaceGetHeight(surfaceRef))

guard expectedWidth > 0, expectedHeight > 0 else { return true }
return abs(actualWidth - expectedWidth) <= 1 && abs(actualHeight - expectedHeight) <= 1
}
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 Data race reading bounds/contentsScale in contents setter

shouldAccept reads self.bounds and self.contentsScale inside the CALayer.contents setter. The whole point of this override is to intercept IOSurface assignments from the Ghostty renderer, which sets contents on the Metal render/commit thread — not on the main thread. Reading CALayer model-layer properties (bounds, contentsScale) from that thread without synchronization is an unsynchronized read that can race with main-thread writes during concurrent resizes, leading to a wrong size comparison or — in pathological cases — a Swift runtime crash. The existing NSLock in nextDrawable() shows the author is already aware of the threading context; the same care is needed here.

A safe approach is to snapshot the expected size on the main thread and store it behind the existing lock so shouldAccept can compare against a stable copy:

// store alongside drawableCount:
private var expectedPixelSize: CGSize = .zero

func updateExpectedSize(bounds: CGRect, scale: CGFloat) {
    lock.lock()
    defer { lock.unlock() }
    let s = max(scale, 1.0)
    expectedPixelSize = CGSize(
        width: (bounds.width * s).rounded(.toNearestOrAwayFromZero),
        height: (bounds.height * s).rounded(.toNearestOrAwayFromZero)
    )
}

private func shouldAccept(contents newValue: Any?) -> Bool {
    // ...
    lock.lock()
    let expected = expectedPixelSize
    lock.unlock()
    let ew = Int(expected.width), eh = Int(expected.height)
    guard ew > 0, eh > 0 else { return true }
    return abs(actualWidth - ew) <= 1 && abs(actualHeight - eh) <= 1
}

Comment on lines +4773 to 4776
// Resize/reflow-heavy apps like tmux can leave stale pixels visible until a
// later wakeup if we don't ask Ghostty for a fresh frame immediately.
ghostty_surface_refresh(surface)
return true
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 ghostty_surface_refresh fires on scale-only changes

The refresh nudge was added to fix stale tmux borders after resize, but because it sits after the combined guard scaleChanged || sizeChanged check it also fires on content-scale-only changes — e.g., when the window moves between Retina and non-Retina displays. On a DPI-only change, ghostty_surface_set_size is skipped, so ghostty_surface_refresh is called immediately after ghostty_surface_set_content_scale before the renderer has had a chance to re-layout the grid at the new scale. This is likely harmless in practice but produces a spurious mid-transition frame and the comment only mentions the tmux resize scenario. Consider gating the call under the if sizeChanged block:

        if sizeChanged {
            ghostty_surface_set_size(surface, wpx, hpx)
            lastPixelWidth = wpx
            lastPixelHeight = hpx
            // Resize/reflow-heavy apps like tmux can leave stale pixels visible until a
            // later wakeup if we don't ask Ghostty for a fresh frame immediately.
            ghostty_surface_refresh(surface)
        }

        return true

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: 3

🤖 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`:
- Around line 6047-6060: refreshSurfaceAfterCommandIfNeeded currently drops any
request that arrives within 50ms of the last call; instead implement a trailing
refresh so rapid calls are coalesced (throttle with a trailing invocation).
Modify refreshSurfaceAfterCommandIfNeeded to keep the existing 50ms early-return
but, when a call is suppressed, schedule a single delayed refresh (e.g., via
DispatchQueue.main.asyncAfter or a stored DispatchWorkItem) to run after the
remaining throttle interval and call terminalSurface.forceRefresh(reason:),
canceling/rescheduling that work item on subsequent suppressed calls; use
lastCommandRefreshAt and terminalSurface.forceRefresh(reason:) as the reference
points and store a small optional scheduledWorkItem property to manage
cancellation/rescheduling.

In `@tests/test_issue_3118_tmux_resize_layout.py`:
- Around line 17-23: The tests start per-test tmux servers running long-lived
sleep processes but never reliably kill them; add a helper function named
_kill_tmux_server(socket_name: str) that invokes tmux -L <socket_name>
kill-server (suppressing stdout/stderr and not raising on failure) and call this
helper from each test's teardown/finally path where tmux sessions are created
(reference the socket name variables used when launching tmux in the tests) so
the tmux server is always cleaned up even on failures; ensure the helper is
defined at module scope and invoked in both places that launch tmux sessions
(the two blocks indicated around lines ~349-405 as well as the initial case).
- Around line 253-259: Update the tmux options in the common list so the
display-time is a short finite timeout instead of 0: replace the entry that sets
"display-time 0" with one that sets "display-time 50" (use the same tmux
variable and session_ref references already in the list) to ensure transient
tmux messages expire before snapshots are taken.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58673a75-be18-4c33-8791-bcbf28dc5f9d

📥 Commits

Reviewing files that changed from the base of the PR and between 03a0c14 and c3ebb52.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • tests/test_issue_3118_tmux_resize_layout.py

Comment on lines +6047 to +6060
private func refreshSurfaceAfterCommandIfNeeded(reason: String) {
guard let terminalSurface,
window != nil,
bounds.width > 0,
bounds.height > 0,
isVisibleInUI else { return }

let now = CACurrentMediaTime()
if now - lastCommandRefreshAt < 0.05 {
return
}
lastCommandRefreshAt = now
terminalSurface.forceRefresh(reason: reason)
}
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

Coalesce throttled command refreshes instead of dropping them.

Line 6055 drops any command refresh inside the 50 ms window. In tmux prefix sequences, the prefix key can trigger the throttle, then the actual layout/resize key (for example an arrow/control-arrow) can arrive inside that window and never get the redraw this PR is trying to guarantee. Keep the throttle, but schedule one trailing refresh.

Proposed fix
-    private var lastCommandRefreshAt: CFTimeInterval = 0
+    private var lastCommandRefreshAt: CFTimeInterval = 0
+    private var pendingCommandRefreshWorkItem: DispatchWorkItem?
     private func refreshSurfaceAfterCommandIfNeeded(reason: String) {
         guard let terminalSurface,
               window != nil,
               bounds.width > 0,
               bounds.height > 0,
               isVisibleInUI else { return }
 
         let now = CACurrentMediaTime()
-        if now - lastCommandRefreshAt < 0.05 {
+        let minimumInterval: CFTimeInterval = 0.05
+        let elapsed = now - lastCommandRefreshAt
+        if elapsed < minimumInterval {
+            guard pendingCommandRefreshWorkItem == nil else {
+                return
+            }
+            let delay = minimumInterval - elapsed
+            let workItem = DispatchWorkItem { [weak self] in
+                guard let self else { return }
+                self.pendingCommandRefreshWorkItem = nil
+                self.refreshSurfaceAfterCommandIfNeeded(reason: reason)
+            }
+            pendingCommandRefreshWorkItem = workItem
+            DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
             return
         }
+        pendingCommandRefreshWorkItem?.cancel()
+        pendingCommandRefreshWorkItem = nil
         lastCommandRefreshAt = now
         terminalSurface.forceRefresh(reason: reason)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 6047 - 6060,
refreshSurfaceAfterCommandIfNeeded currently drops any request that arrives
within 50ms of the last call; instead implement a trailing refresh so rapid
calls are coalesced (throttle with a trailing invocation). Modify
refreshSurfaceAfterCommandIfNeeded to keep the existing 50ms early-return but,
when a call is suppressed, schedule a single delayed refresh (e.g., via
DispatchQueue.main.asyncAfter or a stored DispatchWorkItem) to run after the
remaining throttle interval and call terminalSurface.forceRefresh(reason:),
canceling/rescheduling that work item on subsequent suppressed calls; use
lastCommandRefreshAt and terminalSurface.forceRefresh(reason:) as the reference
points and store a small optional scheduledWorkItem property to manage
cancellation/rescheduling.

Comment on lines +17 to +23
import os
import shlex
import struct
import sys
import time
import zlib
from pathlib import Path
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 | 🟡 Minor

Clean up the per-test tmux servers.

Both cases launch tmux sessions whose panes run sleep 100000, but there is no teardown path. A failure before process/app cleanup can leave tmux servers and child sleeps behind.

Proposed fix
 import os
 import shlex
+import subprocess
 import struct
 import sys
 import time
 import zlib
@@
 def _run_layout_roundtrip_case(c: cmux, token: str) -> None:
     panel_id = _new_workspace_with_panel(c)
     socket_name = f"cmux3118-layout-{token}"
     session_name = f"cmux3118layout{token}"
-    _launch_tmux(
-        c,
-        panel_id,
-        socket_name=socket_name,
-        session_name=session_name,
-        pane_scripts=[_pane_script("A"), _pane_script("B"), _pane_script("C")],
-        setup_commands=[
-            f"tmux -L {shlex.quote(socket_name)} -f /dev/null split-window -h -t {shlex.quote(session_name)}:0 {shlex.quote(_pane_script('B'))}",
-            f"tmux -L {shlex.quote(socket_name)} -f /dev/null split-window -v -t {shlex.quote(session_name)}:0.0 {shlex.quote(_pane_script('C'))}",
-            f"tmux -L {shlex.quote(socket_name)} -f /dev/null select-layout -t {shlex.quote(session_name)}:0 tiled",
-            f"tmux -L {shlex.quote(socket_name)} -f /dev/null select-pane -t {shlex.quote(session_name)}:0.0",
-        ],
-    )
-
-    _wait_for_terminal_focus(c, panel_id)
-    time.sleep(0.5)
-
-    _assert_roundtrip_visual_stability(
-        c,
-        panel_id,
-        label="tmux_layout",
-        apply_change=lambda: _prefixed_shortcut(c, "opt+1"),
-        apply_revert=lambda: _prefixed_shortcut(c, "opt+5"),
-    )
+    try:
+        _launch_tmux(
+            c,
+            panel_id,
+            socket_name=socket_name,
+            session_name=session_name,
+            pane_scripts=[_pane_script("A"), _pane_script("B"), _pane_script("C")],
+            setup_commands=[
+                f"tmux -L {shlex.quote(socket_name)} -f /dev/null split-window -h -t {shlex.quote(session_name)}:0 {shlex.quote(_pane_script('B'))}",
+                f"tmux -L {shlex.quote(socket_name)} -f /dev/null split-window -v -t {shlex.quote(session_name)}:0.0 {shlex.quote(_pane_script('C'))}",
+                f"tmux -L {shlex.quote(socket_name)} -f /dev/null select-layout -t {shlex.quote(session_name)}:0 tiled",
+                f"tmux -L {shlex.quote(socket_name)} -f /dev/null select-pane -t {shlex.quote(session_name)}:0.0",
+            ],
+        )
+
+        _wait_for_terminal_focus(c, panel_id)
+        time.sleep(0.5)
+
+        _assert_roundtrip_visual_stability(
+            c,
+            panel_id,
+            label="tmux_layout",
+            apply_change=lambda: _prefixed_shortcut(c, "opt+1"),
+            apply_revert=lambda: _prefixed_shortcut(c, "opt+5"),
+        )
+    finally:
+        _kill_tmux_server(socket_name)
@@
 def _run_resize_roundtrip_case(c: cmux, token: str) -> None:
     panel_id = _new_workspace_with_panel(c)
     socket_name = f"cmux3118-resize-{token}"
     session_name = f"cmux3118resize{token}"
-    _launch_tmux(
-        c,
-        panel_id,
-        socket_name=socket_name,
-        session_name=session_name,
-        pane_scripts=[_pane_script("L"), _pane_script("R")],
-        setup_commands=[
-            f"tmux -L {shlex.quote(socket_name)} -f /dev/null split-window -h -t {shlex.quote(session_name)}:0 {shlex.quote(_pane_script('R'))}",
-            f"tmux -L {shlex.quote(socket_name)} -f /dev/null select-pane -t {shlex.quote(session_name)}:0.1",
-        ],
-    )
-
-    _wait_for_terminal_focus(c, panel_id)
-    time.sleep(0.5)
-
-    _assert_roundtrip_visual_stability(
-        c,
-        panel_id,
-        label="tmux_resize",
-        apply_change=lambda: _prefixed_shortcut(c, "ctrl+left"),
-        apply_revert=lambda: _prefixed_shortcut(c, "ctrl+right"),
-    )
+    try:
+        _launch_tmux(
+            c,
+            panel_id,
+            socket_name=socket_name,
+            session_name=session_name,
+            pane_scripts=[_pane_script("L"), _pane_script("R")],
+            setup_commands=[
+                f"tmux -L {shlex.quote(socket_name)} -f /dev/null split-window -h -t {shlex.quote(session_name)}:0 {shlex.quote(_pane_script('R'))}",
+                f"tmux -L {shlex.quote(socket_name)} -f /dev/null select-pane -t {shlex.quote(session_name)}:0.1",
+            ],
+        )
+
+        _wait_for_terminal_focus(c, panel_id)
+        time.sleep(0.5)
+
+        _assert_roundtrip_visual_stability(
+            c,
+            panel_id,
+            label="tmux_resize",
+            apply_change=lambda: _prefixed_shortcut(c, "ctrl+left"),
+            apply_revert=lambda: _prefixed_shortcut(c, "ctrl+right"),
+        )
+    finally:
+        _kill_tmux_server(socket_name)

Add this helper outside the selected range:

def _kill_tmux_server(socket_name: str) -> None:
    subprocess.run(
        ["tmux", "-L", socket_name, "kill-server"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )

Also applies to: 349-405

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

In `@tests/test_issue_3118_tmux_resize_layout.py` around lines 17 - 23, The tests
start per-test tmux servers running long-lived sleep processes but never
reliably kill them; add a helper function named _kill_tmux_server(socket_name:
str) that invokes tmux -L <socket_name> kill-server (suppressing stdout/stderr
and not raising on failure) and call this helper from each test's
teardown/finally path where tmux sessions are created (reference the socket name
variables used when launching tmux in the tests) so the tmux server is always
cleaned up even on failures; ensure the helper is defined at module scope and
invoked in both places that launch tmux sessions (the two blocks indicated
around lines ~349-405 as well as the initial case).

Comment on lines +253 to +259
common = [
f"{tmux} set-option -t {session_ref} status off",
f"{tmux} set-option -t {session_ref} display-time 0",
f"{tmux} set-option -t {session_ref} bell-action none",
f"{tmux} set-option -t {session_ref} pane-border-style fg=white",
f"{tmux} set-option -t {session_ref} pane-active-border-style fg=white",
]
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

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the test file to understand the context
head -n 300 tests/test_issue_3118_tmux_resize_layout.py | tail -n 100

Repository: manaflow-ai/cmux

Length of output: 3468


🏁 Script executed:

# Check if the file exists and get its size
wc -l tests/test_issue_3118_tmux_resize_layout.py

Repository: manaflow-ai/cmux

Length of output: 107


🏁 Script executed:

# Look for the specific lines mentioned
sed -n '240,280p' tests/test_issue_3118_tmux_resize_layout.py

Repository: manaflow-ai/cmux

Length of output: 1436


🏁 Script executed:

# Search for settle delay or wait patterns in the test
rg -n "sleep|time|settle|wait" tests/test_issue_3118_tmux_resize_layout.py | head -20

Repository: manaflow-ai/cmux

Length of output: 1158


🏁 Script executed:

# Look at the overall test structure - function names and key logic
rg -n "def |class |display.time|screenshot|snapshot|diff" tests/test_issue_3118_tmux_resize_layout.py

Repository: manaflow-ai/cmux

Length of output: 2468


🌐 Web query:

tmux display-time option meaning documentation

💡 Result:

The tmux display-time option sets the duration in milliseconds for which status line messages and other on-screen indicators (such as those from display-message or display-panes commands) are displayed. The default value is 750 milliseconds. If set to 0, messages and indicators remain displayed until a key is pressed. This is a session option, configurable with commands like set -g display-time 4000 in ~/.tmux.conf or via the command prompt.

Citations:


🏁 Script executed:

# Check tmux documentation or man page references in the repository
rg -i "display.time" --type py --type txt

Repository: manaflow-ai/cmux

Length of output: 165


🏁 Script executed:

# Look at the test flow more carefully to understand when snapshots are taken
# relative to the settle delays and display-time
sed -n '274,340p' tests/test_issue_3118_tmux_resize_layout.py

Repository: manaflow-ai/cmux

Length of output: 2790


🏁 Script executed:

# Check what happens after the apply_change - how quickly are snapshots taken?
sed -n '300,330p' tests/test_issue_3118_tmux_resize_layout.py

Repository: manaflow-ai/cmux

Length of output: 1483


Use a finite display-time to prevent tmux messages from polluting snapshots.

Line 255 sets display-time 0, which makes tmux status messages and indicators persist on screen until a key is pressed. This can pollute the final snapshot after layout/resize operations and cause the pixel-diff assertions to fail incorrectly. Use a short finite timeout like 50ms, which will expire well within the existing settle delays (150ms before snapshots, 350ms after shortcuts) before any snapshot is captured.

Proposed fix
     common = [
         f"{tmux} set-option -t {session_ref} status off",
-        f"{tmux} set-option -t {session_ref} display-time 0",
+        f"{tmux} set-option -t {session_ref} display-time 50",
         f"{tmux} set-option -t {session_ref} bell-action none",
         f"{tmux} set-option -t {session_ref} pane-border-style fg=white",
         f"{tmux} set-option -t {session_ref} pane-active-border-style fg=white",
     ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
common = [
f"{tmux} set-option -t {session_ref} status off",
f"{tmux} set-option -t {session_ref} display-time 0",
f"{tmux} set-option -t {session_ref} bell-action none",
f"{tmux} set-option -t {session_ref} pane-border-style fg=white",
f"{tmux} set-option -t {session_ref} pane-active-border-style fg=white",
]
common = [
f"{tmux} set-option -t {session_ref} status off",
f"{tmux} set-option -t {session_ref} display-time 50",
f"{tmux} set-option -t {session_ref} bell-action none",
f"{tmux} set-option -t {session_ref} pane-border-style fg=white",
f"{tmux} set-option -t {session_ref} pane-active-border-style fg=white",
]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_issue_3118_tmux_resize_layout.py` around lines 253 - 259, Update
the tmux options in the common list so the display-time is a short finite
timeout instead of 0: replace the entry that sets "display-time 0" with one that
sets "display-time 50" (use the same tmux variable and session_ref references
already in the list) to ensure transient tmux messages expire before snapshots
are taken.

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 2 files

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:6055">
P2: The 50ms throttle currently drops command-triggered refreshes outright. For multi-key tmux command sequences, this can skip the refresh on the actual layout/resize key and leave stale pixels until a later wakeup. Coalesce a trailing refresh instead of returning immediately.</violation>
</file>

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

isVisibleInUI else { return }

let now = CACurrentMediaTime()
if now - lastCommandRefreshAt < 0.05 {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

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

P2: The 50ms throttle currently drops command-triggered refreshes outright. For multi-key tmux command sequences, this can skip the refresh on the actual layout/resize key and leave stale pixels until a later wakeup. Coalesce a trailing refresh instead of returning immediately.

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

<comment>The 50ms throttle currently drops command-triggered refreshes outright. For multi-key tmux command sequences, this can skip the refresh on the actual layout/resize key and leave stale pixels until a later wakeup. Coalesce a trailing refresh instead of returning immediately.</comment>

<file context>
@@ -5988,6 +6019,46 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
+              isVisibleInUI else { return }
+
+        let now = CACurrentMediaTime()
+        if now - lastCommandRefreshAt < 0.05 {
+            return
+        }
</file context>
Fix with Cubic

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.

tmux pane resize / layout changes render incorrectly inside cmux

1 participant