Skip to content

iOS: stop terminal zoom + push-up during keyboard transitions - #8907

Merged
azooz2003-bit merged 2 commits into
mainfrom
feat-ios-kb-grid-stability
Jul 29, 2026
Merged

azooz2003-bit merged 2 commits into
mainfrom
feat-ios-kb-grid-stability

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Dogfood feedback on the kbpin build (#8899, since merged): the terminal font visibly zoomed when the software keyboard closed and snapped back a moment later, and rows were pushed off the top of the screen mid-transition before sliding back down. Field recording analysis plus a simulator repro against a live Mac narrowed it to three defects in the shared-grid negotiation around a keyboard container change.

  1. Stale-grant font stretch. The stretch-to-fill auto-fit ran on the geometry pass right after the keyboard target changed, while effectiveGrid still held the previous grant (zoom.autofit eff=72x34 baseGrid=72x65 font 10.0->18.98 captured live at dismissal start). It stretched the rendered font toward filling the new container with the stale row count and decayed one RPC round-trip later — the "text zooms in when the keyboard closes" glitch. The fit now waits for a settled negotiation (no keyboard animation in flight, no debouncing report, newest report echo confirmed, pass capacity equal to the last reported grid); the settle paths (animation completion, echo confirmation, retry exhaustion) each schedule one final sync so exactly one fit runs on the settled grant.

  2. Live-viewport bottom-pin overshoot. The render rect bottom-pinned to the live viewport unconditionally. During a dismissal the surface is already sized for the taller target viewport, so the pin pushed the top rows off screen by renderHeight - liveHeight (renderRect=440x766@-344 in the captured log) and they slid back down as the keyboard left. TerminalLetterboxGeometry.renderPinnedBottomEdge now caps the clip at the settled amount, and a provisionally pinned render (negotiation unsettled) holds its top edge instead of riding the departing keyboard down and snapping back up when the fresh grant unpins it. Settled letterbox boxes and the keyboard-rise path keep the legacy live-edge ride.

  3. Incoherent cell/font pairs in capacity reports. Reports normalized the measured cell size with liveFontSize read at apply time; a font change queued between the measurement and the apply skews the base-font normalization by the zoom ratio and under-reports the grid several-fold (the ~10-row grants visible in the field recording, which seed the oscillation). The geometry pass now captures the font it measured with, and the report/fit use that paired value.

Verification: CmuxMobileTerminalKit host tests green (110 tests, includes new renderPinnedBottomEdge cases); simulator E2E dance (keyboard raise + dismiss over a live attach) recorded and frame-analyzed on this branch — text top position and row pitch constant through both transitions; debug log shows zoom.autofit.deferred during transitions, renderRect=440x397@0 held through the dismissal, then the echoed grant growing the render in place with zero motion. A host-runnable behavior test for defects 1/3 is not practical (they live in the iOS-only CmuxMobileTerminal target, which does not resolve on macOS hosts); the Kit pin math is unit-tested and the rest is covered by the recorded E2E evidence.

No user-facing strings changed (debug log lines only), so no localization updates.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes iOS keyboard transition glitches: stops the terminal font from zooming on keyboard close and prevents rows from being pushed off-screen during show/hide. Grid negotiation, render pinning, and local resizing now stay stable throughout keyboard animations.

  • Bug Fixes
    • Deferred stretch-to-fill until the negotiation settles; added awaitingViewportEcho, markViewportReportConfirmed(reportID:), and a final geometry sync on settle/timeout.
    • Stopped content push-up: on dismissal, capped the live-bottom pin with TerminalLetterboxGeometry.renderPinnedBottomEdge(..., holdsProvisionalPin:); on keyboard rise, deferred local shrink resize (skip set_size and letterbox fit), kept the old render, avoided re-stamping source-layout height, and applied one resize on settle. Tracked the last applied container size and reset it on pipeline recreation; width changes and growth still resize immediately. Added unit tests in CmuxMobileTerminalKit.
    • Paired capacity reports with the measured font. Captured measuredFontSize in the geometry pass and used it in capacityReportGrid and autoFitFontToEffectiveRows to prevent bogus small-grid grants.

Written for commit 66e897c. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved terminal layout stability during keyboard transitions and viewport resizing.
    • Prevented temporary rendering jumps while viewport updates are still being confirmed.
    • Confirm viewport reports with the correct identifier to avoid stale confirmation.
    • Ensured font fitting uses the measured font size from the geometry pass.
    • Refined pinned-edge rendering during unsettled viewport negotiation and improved recovery when retries can’t complete.
  • Tests
    • Added coverage for pinned-edge behavior in steady state, keyboard appearance, dismissal, and transitional growth.

Dogfood of the kbpin build showed two glitches synchronized with the
software keyboard: the terminal font visibly zoomed when the keyboard
closed (then snapped back), and rows were shoved off the top of the
screen mid-transition before sliding back down.

Three causes, all in the shared-grid negotiation around a keyboard
container change:

1. The stretch-to-fill auto-fit ran on every geometry pass, including
   the pass right after the keyboard target changed, while
   effectiveGrid still held the PREVIOUS grant (the phone itself just
   invalidated it). It stretched the rendered font toward filling the
   new container with the stale row count and decayed one RPC
   round-trip later. The fit is now deferred until the negotiation is
   settled: no keyboard animation in flight, no report debouncing, the
   newest report's echo confirmed, and the pass's capacity equal to
   the last reported grid. The settle paths (animation completion,
   echo confirmation, retry exhaustion) each schedule one final sync
   so exactly one fit runs on the settled grant.

2. The render rect bottom-pinned to the LIVE viewport
   unconditionally. During a dismissal the surface is already sized
   for the taller target viewport, so pinning to the still-small live
   bottom pushed the top rows off screen by renderHeight - liveHeight
   (renderRect y hit -344pt in the captured logs) and they slid back
   as the keyboard left. TerminalLetterboxGeometry.renderPinnedBottomEdge
   now caps the clip at the settled amount, and while the negotiation
   is unsettled a provisionally pinned render holds its top edge
   instead of riding the departing keyboard down and snapping back up
   on the fresh grant. Settled letterbox boxes and the keyboard-rise
   path keep the legacy live-edge ride.

3. Capacity reports normalized the measured cell size with the
   main-actor liveFontSize read at apply time. A font change queued
   between the measurement and the apply broke the base-font
   normalization by the zoom ratio, reporting a grid several times too
   small and feeding bogus grants back into the loop (the 10-row
   grants visible in the field recording). The geometry pass now
   captures the font it measured with and the report/fit use that
   paired value.

Verified on cmux-kbpin-sim against a live Mac kbpin instance: debug
log shows zoom.autofit.deferred during transitions, renderRect pinned
at y=0 through the dismissal (previously -344), no font change across
the whole cycle, and frame analysis of the recorded dance shows the
text top and pitch constant through both transitions.
@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Viewport negotiation now waits for the latest daemon echo before stretch-to-fill fitting, preserves the font size used during geometry measurement, and pins rendering through keyboard and layout transitions using shared bottom-edge geometry.

Changes

Viewport stabilization

Layer / File(s) Summary
Pinned render geometry and coverage
Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalViewport*.swift, Packages/iOS/CmuxMobileTerminalKit/Tests/.../TerminalLetterboxGeometryTests.swift
Adds provisional bottom-edge pinning for unsettled viewport rendering and tests steady-state, keyboard, dismissal, and crossover behavior.
Viewport echo confirmation and deferred fitting
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift
Tracks the newest natural-grid report, confirms matching report identifiers, defers fitting until confirmation, releases it when retries fail, and resets container-size state during render recovery.
Measured-font geometry and capacity fitting
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+ViewportCapacityFit.swift
Threads the geometry-pass font size through capacity calculations, defers unsettled shrink resizing, and restricts auto-fitting to settled, matching geometry reports.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GhosttySurfaceView
  participant Daemon
  participant TerminalViewportSnapshot
  participant TerminalLetterboxGeometry
  GhosttySurfaceView->>Daemon: send latest natural-grid report
  GhosttySurfaceView->>TerminalViewportSnapshot: mark negotiation unsettled
  Daemon-->>GhosttySurfaceView: echo report.id
  GhosttySurfaceView->>GhosttySurfaceView: release deferred auto-fit
  GhosttySurfaceView->>TerminalViewportSnapshot: apply settled geometry
  TerminalViewportSnapshot->>TerminalLetterboxGeometry: calculate pinned bottom edge
  TerminalLetterboxGeometry-->>TerminalViewportSnapshot: return render position
Loading

Suggested reviewers: lawrencecchen, lawrencecchen


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Cmux No Ambient Global State ❌ Error TerminalLetterboxGeometry is a static-helper namespace, and this PR adds renderPinnedBottomEdge to it at line 256. Move the pinning math onto an owning, constructable type or make it a private/fileprivate helper in the caller instead of extending the static-helper namespace.
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (23 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Changes stay in MainActor UI coordinators/helpers; no new Sendable reference types or background access to UI-bound state were introduced.
Cmux Swift Blocking Runtime ✅ Passed PASS: The diff only adds state/geometry refits and a reset hook; no new semaphores, waits, sleeps, timers, sync calls, or locks appear in changed production Swift.
Cmux Browser Automation Off-Main ✅ Passed PR only changes iOS terminal viewport/geometry code; no browser.* socket automation commands, routing, or policy tests were touched.
Cmux Expensive Synchronous Load ✅ Passed Diff only changes viewport/geometry code; patch scan found no RestorableAgentSessionIndex.load, Data(contentsOf:), JSON parsing, or other agent-history loads.
Cmux Cache Substitution Correctness ✅ Passed The added cached geometry state is transient UI-only, and the snapshot/render path freshness-checks it; no persistence/history/undo substitution is introduced.
Cmux No Hacky Sleeps ✅ Passed The diff touches only Swift files; no TypeScript/JS/shell/build runtime code or fixed sleeps/timers were introduced, so this rule is not triggered.
Cmux Algorithmic Complexity ✅ Passed No changed production path adds unbounded collection scans; the new viewport/geometry logic is constant-time, and added tests are fixed-size.
Cmux Swift Concurrency ✅ Passed Changed hunks add synchronous viewport/geometry logic only; no new Dispatch/Combine/completion-handler or fire-and-forget Task patterns appear in the diff.
Cmux Swift @Concurrent ✅ Passed No new nonisolated async work lacks @concurrent; the added async paths are UI-bound and already hop via @MainActor/outputQueue.
Cmux Swift Package Boundaries ✅ Passed PASS: The diff keeps Ghostty/UI composition in CmuxMobileTerminal and extracts reusable geometry math to CmuxMobileTerminalKit; no shared domain logic is stranded in the app target.
Cmux Swiftpm Lockfiles ✅ Passed Diff only touches two Swift sources; no Package.swift/.resolved, .gitignore, Xcode, or workflow files changed, so the lockfile rule isn’t implicated.
Cmux Swift Logging ✅ Passed New log uses DEBUG-only MobileDebugLog.anchormux; no print/debugPrint/dump/raw NSLog or secret-exposing logs were added.
Cmux User-Facing Error Privacy ✅ Passed The diff only adds internal state, comments, and a debug log; no user-facing error, alert, or recovery copy was introduced.
Cmux Full Internationalization ✅ Passed Diff only changes internal geometry/viewport logic, tests, and debug logs; no user-facing text or locale assets were added or changed.
Cmux Swiftui State Layout ✅ Passed The diff only adds plain structs/helpers plus a UIKit bridge; no new ObservableObject/@published, GeometryReader layout measurement, or render-time state mutation appears.
Cmux Architecture Rethink ✅ Passed The PR keeps viewport state in GhosttySurfaceView with value-snapshot bridges, and adds no sleeps, polling, locks, or split lifecycle ownership.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: The PR only changes GhosttySurfaceView geometry/render recovery; no NSWindow/NSPanel/WindowGroup/Window code or cmuxAuxiliaryWindowIdentifiers were touched.
Cmux Source Artifacts ✅ Passed All 9 changed paths are intentional Swift source/test files under Sources/Tests; no logs, caches, temp dirs, build output, or scratch artifacts appear.
Cmux No Test Or Debug Seam In Production Source ✅ Passed PASS: New helpers are production-used (recoverRenderPipeline and ShellUI’s markViewportReportConfirmed(reportID:)); no new debug*/ForTesting seam or DEBUG-only test hook was added.
Title check ✅ Passed The title clearly summarizes the main iOS keyboard-transition fix.
Description check ✅ Passed The description is detailed and covers the Summary and Testing aspects of the template.
✨ 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 feat-ios-kb-grid-stability

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.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR stabilizes iOS terminal geometry during software-keyboard transitions.

  • Defers font auto-fitting until viewport negotiation has settled.
  • Preserves coherent measured-cell and font-size pairs in capacity calculations.
  • Holds provisional render geometry during keyboard transitions and adds unit coverage for bottom-pinning behavior.
  • Resets applied-container bookkeeping when the render pipeline is recreated.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Coordinates deferred resizing, report confirmation, measured-font pairing, and settled auto-fit behavior.
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+ViewportCapacityFit.swift Normalizes capacity and auto-fit calculations using the font paired with each geometry measurement.
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift Resets applied-container geometry when recreating the terminal render surface.
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalViewportSnapshot.swift Routes render placement through negotiation-aware bottom-edge pinning.
Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift Adds reusable geometry for stable render pinning during viewport growth and shrinkage.
Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalLetterboxGeometryTests.swift Covers steady, keyboard-rise, keyboard-dismissal, and provisional-pinning cases.

Sequence Diagram

sequenceDiagram
  participant UIKit as Keyboard transition
  participant Surface as GhosttySurfaceView
  participant Mac as Paired Mac
  UIKit->>Surface: Update target viewport
  Surface->>Surface: Hold provisional render position
  Surface->>Mac: Report settled base-font capacity
  Mac-->>Surface: Echo effective grid
  Surface->>Surface: Confirm newest report
  Surface->>Surface: Apply final geometry and font fit
Loading

Reviews (2): Last reviewed commit: "iOS: defer local shrink resize until the..." | Re-trigger Greptile

The keyboard-rise direction still showed a momentary "all rows pushed
up": the local mirror resized to the smaller container immediately,
and its reflow keeps the bottom of the SCREEN (trailing blank rows
included), so the visible content collapsed to the tail of the old
screen jumped to the top until the remote reflow landed one round-trip
later.

While the negotiation is unsettled and the container shrank at the
same width (keyboard rising), the geometry pass now skips the local
set_size and letterbox fit: the old render keeps its size and the
bottom-pinned render rect slides it up with the keyboard, prompt glued
to the keyboard top. The capacity report is pure container/cell math,
so the negotiation still starts immediately, and the settle pass
(echo confirmed, or retries exhausted) applies ONE resize whose result
matches the remote's reflowed content. Deferred passes also skip
re-stamping the render's source-layout height so the stale-live clamp
cannot snap the old render to the target viewport mid-ride, and the
applied-container tracker resets with the render pipeline.

Width changes (rotation, split) and growth keep the immediate resize.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift (1)

3607-3644: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not auto-fit a deferred geometry result.

A deferred pass sets result.appliedResize == false, but this branch only checks the current negotiation flags. If those flags clear before the queued result reaches the MainActor, stale pre-resize geometry can trigger autoFitFontToEffectiveRows before the settle pass calls ghostty_surface_set_size, reintroducing font oscillation.

Require result.appliedResize and use snapshot.viewportNegotiationUnsettled as the single settled-state source.

Proposed fix
-        if keyboardHeightAnimation == nil,
-           pendingViewportReport == nil,
-           !awaitingViewportEcho,
+        if result.appliedResize,
+           !snapshot.viewportNegotiationUnsettled,
            reportGrid == lastReportedSize {

As per path instructions, correctness-critical viewport negotiation should use one reliable source of truth and fail closed rather than relying on a second potentially stale state calculation.

🤖 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
`@Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`
around lines 3607 - 3644, Update the auto-fit guard around
autoFitFontToEffectiveRows so it requires result.appliedResize and uses
snapshot.viewportNegotiationUnsettled as the sole settled-state check, negated.
Remove the duplicated keyboard/report negotiation flag checks from this decision
and preserve the existing deferred diagnostic logging as appropriate, ensuring
deferred or unsettled geometry cannot trigger auto-fitting.

Source: Path instructions

🤖 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
`@Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 3607-3644: Update the auto-fit guard around
autoFitFontToEffectiveRows so it requires result.appliedResize and uses
snapshot.viewportNegotiationUnsettled as the sole settled-state check, negated.
Remove the duplicated keyboard/report negotiation flag checks from this decision
and preserve the existing deferred diagnostic logging as appropriate, ensuring
deferred or unsettled geometry cannot trigger auto-fitting.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0ace9bc5-558f-490c-8d5d-edcedd2f7e1b

📥 Commits

Reviewing files that changed from the base of the PR and between 386ec7d and 66e897c.

📒 Files selected for processing (2)
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Round 3 (66e897c), from follow-up dogfood: the keyboard-RISE direction still showed a momentary "all rows pushed up". The local mirror resized to the smaller container immediately, and its reflow keeps the bottom of the screen including trailing blank rows, so the visible content collapsed to the tail of the old screen at the top until the remote reflow landed one round-trip later.

The geometry pass now defers the local set_size (and letterbox fit) while the negotiation is unsettled and the container shrank at the same width: the old render keeps its size and the bottom-pinned render rect slides it up with the keyboard, prompt glued to the keyboard top; the capacity report is pure container/cell math so the negotiation still starts immediately, and the settle pass applies one resize whose result matches the remote's reflowed content. Deferred passes skip re-stamping the render's source-layout height so the stale-live clamp cannot snap the old render to the target viewport mid-ride. Width changes (rotation/split) and growth keep the immediate resize.

Kit tests green (110). Visual sim verification of this round was blocked by a wedged sim-to-Mac iroh link in the verification rig; rounds 1/2 evidence and the round-3 mechanism are covered above, and the rise path needs a device check (raise keyboard: rows should squeeze/ride, never flash the screen tail at the top).

🤖 Generated with Claude Code

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.

1 participant