Skip to content

Fix terminal viewport width reporting in cmux#2926

Open
austinywang wants to merge 1 commit intomainfrom
issue-2925-terminal-horizontal-cutoff
Open

Fix terminal viewport width reporting in cmux#2926
austinywang wants to merge 1 commit intomainfrom
issue-2925-terminal-horizontal-cutoff

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Apr 15, 2026

Summary

  • make the scroll host's visible terminal viewport the single source of truth for Ghostty surface sizing
  • use that viewport for initial surface creation, NSView resize updates, and host geometry sync so PTY cols match the actually drawn grid
  • seed host geometry before attaching a surface when real bounds are available to avoid reintroducing the bootstrap width

Testing

  • not run locally (repo policy forbids local test runs)

Fixes #2925


Note

Medium Risk
Touches core terminal sizing/layout paths that affect PTY cols/rows and rendering during resize/scroll; bugs here could cause visual glitches or incorrect terminal dimensions, but no security- or data-sensitive logic is involved.

Overview
Fixes terminal size/viewport reporting by making the scroll host’s rendered viewport rect the single source of truth for Ghostty surface geometry.

Surface creation, host layout sync (synchronizeGeometryAndContent), and core size pushes (synchronizeCoreSurface) now derive width/height from renderedSurfaceViewportRect, and surface attachment pre-seeds geometry when bounds are usable to avoid reusing the bootstrap size.

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


Summary by cubic

Fixes horizontal cutoff by using the scroll view’s visible rect as the terminal viewport for surface sizing and PTY geometry. Reported columns now match the drawn grid across create, resize, and sync; fixes #2925.

  • Bug Fixes
    • Make the scroll host’s visible viewport the single source of truth for surface size.
    • Apply that viewport to surface creation, NSView resize updates, and host geometry sync to avoid width drift.
    • Seed geometry when real bounds exist and size document/surface to the viewport width (excluding scrollbar).

Written for commit 1df203a. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved terminal surface size calculation to accurately determine viewport geometry, addressing layout issues when the terminal is hosted within scrollable containers.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 15, 2026

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

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f3864350-db1b-4a5e-9179-4c0d25824daf

📥 Commits

Reviewing files that changed from the base of the PR and between c5f2e8c and 1df203a.

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

📝 Walkthrough

Walkthrough

Consolidated Ghostty surface sizing logic by introducing renderedSurfaceViewportRect() as the authoritative viewport definition and refactoring multiple synchronization methods (synchronizeGeometryAndContent(), synchronizeSurfaceView(), synchronizeCoreSurface()) to derive size calculations from this single source, eliminating two previously disagreeing code paths that called ghostty_surface_set_size.

Changes

Cohort / File(s) Summary
Surface Viewport Geometry Consolidation
Sources/GhosttyTerminalView.swift
Introduced surfaceHostView wiring and renderedSurfaceViewportRect(preferredSize:) to establish authoritative viewport geometry. Refactored synchronizeGeometryAndContent(), synchronizeSurfaceView(), and synchronizeCoreSurface() to compute layout/libghostty size from this unified source instead of scrollView.bounds.size or view.bounds.size. Changed resolvedSurfaceSize() visibility from private to fileprivate and added fast path for host viewport resolution.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 Two paths once wandered, causing terminal woe,
With columns too wide and overflow below.
Now geometry unites through a viewport's true sight,
One source of truth makes the terminal right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Fix terminal viewport width reporting in cmux' is vague and does not accurately reflect the substantial changes made to viewport geometry synchronization in Ghostty surface sizing logic. Revise title to be more specific about the core change, such as 'Make scroll viewport the single source of truth for Ghostty surface sizing' or 'Consolidate surface geometry sync to use visible viewport rect'.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The PR description covers summary and testing, but is missing a demo video and does not include the review trigger checklist items explicitly addressed.
Linked Issues check ✅ Passed The code changes directly address all objectives from issue #2925: consolidating surface sizing paths, using visible viewport as source of truth, and fixing PTY column reporting to match drawn grid.
Out of Scope Changes check ✅ Passed All changes are narrowly scoped to viewport geometry synchronization in Ghostty surface initialization and resizing, directly aligned with the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ 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-2925-terminal-horizontal-cutoff

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 15, 2026

Greptile Summary

This PR makes scrollView.contentView.documentVisibleRect — the scroll host's actual visible viewport — the single source of truth for Ghostty surface sizing. The key mechanism is the new renderedSurfaceViewportRect method on GhosttySurfaceScrollView, which is wired into surface creation (via GhosttyNSView.resolvedSurfaceSize), NSView layout updates, host geometry sync, and a new pre-attach geometry seed in attachSurface. The approach correctly places all frame assignments after scrollView.layoutSubtreeIfNeeded(), ensuring the reported width excludes any scrollbar gutter.

Confidence Score: 5/5

Safe to merge; remaining findings are P2 style/cleanup suggestions with no correctness impact.

No P0 or P1 issues found. The viewport-as-source-of-truth design is sound and all fallback paths in renderedSurfaceViewportRect handle zero-size and bootstrap cases correctly. The two P2 notes (double sync call touching the old surface, redundant intermediate frame assignment) are cleanups and do not affect runtime behavior.

No files require special attention — both findings are in synchronizeGeometryAndContent / attachSurface within Sources/GhosttyTerminalView.swift and are style-level only.

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Makes clip-view's documentVisibleRect the authoritative terminal viewport, wiring it through surface creation, NSView resize, and host geometry sync. Logic is sound but two minor redundancies are introduced: a double synchronizeGeometryAndContent call in attachSurface and a redundant intermediate surfaceView.frame assignment within synchronizeGeometryAndContent itself.

Sequence Diagram

sequenceDiagram
    participant Host as GhosttySurfaceScrollView
    participant NSV as GhosttyNSView (surfaceView)
    participant Ghostty as libghostty

    Note over Host: attachSurface() called
    alt hasUsableBounds (pre-attach seed)
        Host->>Host: synchronizeGeometryAndContent() [Call 1]
        Host->>Host: scrollView.layoutSubtreeIfNeeded()
        Host->>Host: renderedSurfaceViewportRect() → documentVisibleRect
        Host->>NSV: setFrame(origin: old, size: viewportSize)
        Host->>Host: synchronizeSurfaceView() → surfaceView.frame = visibleRect
        Host->>Host: synchronizeCoreSurface() → pushTargetSurfaceSize (old surface)
    end
    Host->>NSV: attachSurface(terminalSurface)
    NSV->>NSV: resolvedSurfaceSize(preferred: bounds.size)
    NSV->>Host: renderedSurfaceViewportRect(preferredSize: bounds.size)
    Host-->>NSV: viewportSize (from documentVisibleRect)
    NSV->>Ghostty: ghostty_surface_set_size(wpx, hpx)
    alt hasUsableBounds (post-attach sync)
        Host->>Host: synchronizeGeometryAndContent() [Call 2]
        Host->>Host: renderedSurfaceViewportRect() → documentVisibleRect
        Host->>Host: synchronizeCoreSurface() → pushTargetSurfaceSize (new surface)
        Host->>Ghostty: ghostty_surface_set_size (correct viewport)
    end
Loading

Comments Outside Diff (1)

  1. Sources/GhosttyTerminalView.swift, line 9441-9453 (link)

    P2 surfaceView.frame assigned twice within the same CATransaction

    targetSurfaceFrame is built with surfaceView.frame.origin (the stale origin) and targetSize, so setFrameIfNeeded writes (oldOrigin, targetSize). A few lines later, synchronizeSurfaceView() calls renderedSurfaceViewportRect again and, when the origin differs (e.g. scroll-history offset), overwrites the frame to (scrollOrigin, targetSize). Since both calls use the same renderedSurfaceViewportRect result, the intermediate (oldOrigin, targetSize) frame is never rendered but is still an extra AppKit frame mutation. Using targetViewportRect directly for the surface frame avoids the redundant intermediate write:

    _ = setFrameIfNeeded(surfaceView, to: targetViewportRect)

    (and synchronizeSurfaceView would then always be a no-op immediately after)

Reviews (1): Last reviewed commit: "Fix Ghostty viewport width reporting" | Re-trigger Greptile

Comment on lines 9622 to 9635
func attachSurface(_ terminalSurface: TerminalSurface) {
let hasUsableBounds = bounds.width > 1 && bounds.height > 1
if hasUsableBounds {
_ = synchronizeGeometryAndContent()
}
surfaceView.attachSurface(terminalSurface)
let workspace = terminalSurface.owningWorkspace()
cachedOwningWorkspace = workspace
updateWorkspaceTerminalScrollBarObserver(workspace)
// Preserve the bootstrap 800x600 surface until portal reattach churn
// has produced a real host size instead of a transient 1x1 placeholder.
guard bounds.width > 1, bounds.height > 1 else { return }
guard hasUsableBounds else { return }
_ = synchronizeGeometryAndContent()
}
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 Double synchronizeGeometryAndContent runs synchronizeCoreSurface on the old surface

When hasUsableBounds is true, synchronizeGeometryAndContent() fires twice — Call 1 (pre-attach, lines 9624–9626) and Call 2 (post-attach, line 9634). Call 1 reaches synchronizeCoreSurfacesurfaceView.pushTargetSurfaceSize(...) while surfaceView.terminalSurface is still the previous surface (or nil), sending a redundant resize event to that old surface before it is replaced. The intent of Call 1 is only to seed surfaceView.frame so resolvedSurfaceSize returns the correct viewport during the imminent TerminalSurface.create() call. Extracting just the frame/document layout portion — or at minimum adding a comment explaining the desired pre-attach side effects of the full sync — would prevent the unintended push to the outgoing surface.

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.

No issues found across 1 file

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 1df203a. Configure here.

logLayoutDuringActiveDrag(targetSize: targetSize)
#endif
let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize)
_ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Surface view frame set redundantly in geometry sync

Low Severity

In synchronizeGeometryAndContent, surfaceView.frame is set twice: first via setFrameIfNeeded (with the stale current origin and viewport size), then immediately overwritten by synchronizeSurfaceView() which now sets the entire frame from renderedSurfaceViewportRect. Before this PR, synchronizeSurfaceView only set the origin, so both calls were needed—one for size, one for origin. Now that synchronizeSurfaceView sets origin and size, the first setFrameIfNeeded(surfaceView, ...) is fully redundant. The double-set also means renderedSurfaceViewportRect is evaluated three times per sync pass instead of the two that are actually needed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1df203a. Configure here.

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 help output misrenders: huge horizontal padding with right-edge truncation

1 participant