Skip to content

m29 drags: a terminal's program gets its size during a divider drag, coalesced at 25 ms, only sizes shown on screen - #36631

Merged
lawrencecchen merged 10 commits into
feat-cmux-nextfrom
fix/m29-drags-coalesced-grid
Nov 5, 2026
Merged

lawrencecchen merged 10 commits into
feat-cmux-nextfrom
fix/m29-drags-coalesced-grid

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

cmux-ad m18/m29: during a person's divider or column-edge drag, the program in each terminal now gets its new size while the drag moves, coalesced as Ghostty.app does (the newest grid at most once per 25 ms), and only a size that a commit already showed on screen. Before, a full-screen program kept its start size for the whole drag (the terminal background grew beside it) and jumped at the mouse-up.

Stacked on #36553 (TerminalGridReportCoalescer). Until #36553 merges, this diff also shows its two commits.

Why

  • A drag held each terminal's grid report until the landing (updateSurfaceSize: holding: ... || isFollowingResizeGesture), so the PTY got one grid per drag.
  • The reflow preview re-wraps the normal screen per frame, but not the alternate screen. A full-screen program (vim, htop, an agent TUI) froze at its start size, then jumped at the mouse-up (the RESIZE OWNER's film of px-col-drag-2-sw-terminals at 96ad37a, STATUS 14461).

Fix

  • TerminalGridReporter (new): every grid report of a terminal view goes through one TerminalGridReportCoalescer, so its last sent grid is the PTY's.
    • A drag's grid is coalesced (25 ms window, the newest goes).
    • Every other report (at rest, forced, a layout spring's final grid) goes now; it also drops a drag grid still waiting.
  • A drag's grid is the grid of a size shown on screen: at once when the bounds take the size, else from the presenter's landing (TerminalSurfaceView.frameDidLand, one call site; key resizes reuse it, frame-owner-s4's T-KEYS Frame owner S4 (m18/m26): key resizes show each terminal at its pane size in every frame (drawn ahead, never stretched); PTY size coalesced 25 ms; no key hold; switch deleted #36478).
  • updateSurfaceSize no longer holds the grid for a followed drag or its release spring.
  • The drag's end, and a surface that stops following (it left its host or window), send the waiting grid now.
  • Split dividers and column or row edges share the path: every handle kind reaches the same followers through ScreenContentView.resizeGestureMoved.

Changelog

  • Fixed: A full-screen terminal program (vim, htop, an agent) resizes while you drag a divider or column edge, instead of jumping when you let go.

Tests

Run Ref Step Result
red: PTY grids during a drag 2d7728c6556 (test only, on #36553) 10980491e9da4567245aab87 (cmux9) FAIL as expected: DividerDragTerminalResizeCountTests.aDividerDragResizesEachPTYWhileItMovesCoalescedAndAtTheLanding, during.count → 0 >= 2 for both terminals (no PTY grid while the drag moves)
red: every drag grid is a shown frame's (display), first try cffa23bf829 (scratch, feat merged) 78cf9923598483a4ac2ab151 failed, but the head failed the same way: the test was wrong (below)
green: display suites, first try cc8adfffe32 3c8a6bb8bc97b90b3702ac3e failed: TerminalDragShownGridTests 46 grids "never shown", the same failure as the red run
green: 14 suites cc8adfffe32 (feat merged, ghostty-next 383b27d73717) 8fc860667acd00dd0ffad227 ci-step m29z-green exited 0
red: every drag grid is a shown frame's (display), corrected test 7d4c62ebaef (scratch, feat merged, no shown-grid fix) 9e5afccfbbc7ee78a1ef0caa FAIL as expected: 16 grids sent before any commit showed them (e.g. p1 57x33 while the shown frames were 58x33..61x33: the asked size, one ahead)
green: display, corrected test 5b4fdce482d d6d71b43e7ddca29c1fbc68f ci-step m29y-green-display exited 0: 23 PTY grids per terminal during the drag, 0 never shown, newest grid at rest = the view's
app build cc8adfffe32 301042ed0b1a173f19e54759 done
launch smoke (fresh-home) build 76aa7e0b 8960bd3a0d3b602c1f8e481c exit 0 (cmux12)

Observed / Not run

  • Why the first display test proved nothing: it counted a size as shown only when no next size was in flight, but a drag asks its next size in the same pass as a landing, so it recorded almost no drag frames and called nearly every grid "never shown", with and without the fix. 5b4fdce482d counts every committed frame whose layer and presented frame have one pixel size. It must still fail on 7d4c62ebaef (no shown-grid fix) for a grid the program got that no commit showed.

  • Observed: the app build and the launch smoke above; prepush gate, check-no-godfiles (TerminalSurfaceView got smaller; the new file is 70 lines).

  • Not run yet: the display red and the two green steps above (queued; the display pool has 3 workers paused for disk). Films after merge.

  • Review: none yet. Codex was not run on this branch.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes terminal resizing during divider and column-edge drags so the running program gets its new size while the drag moves, instead of jumping at the mouse-up. A full-screen program (vim, htop, an agent TUI) previously kept its start size for the whole drag with the terminal background beside it; now its grid is coalesced like Ghostty.app's (the newest grid at most once per 25 ms), and only grids whose size a commit actually showed on screen are sent.

  • Adds TerminalGridReporter and TerminalGridReportCoalescer: every grid report goes through one 25 ms coalescing window, with drags coalesced and all other reports (rest, forced, layout-spring finals) sent immediately.
  • A drag's grid is sent once its size is shown on screen, either immediately when the bounds take it or from the presenter's landing (frameDidLand); a report sent by now() also drops any drag grid still waiting for its size to show.
  • updateSurfaceSize no longer holds grid reports during a followed drag or its release spring; the drag's end and surfaces that stop following flush the waiting grid.
  • Covers split dividers and column/row edges through the same path.
  • The shown-grid display test now records a committed frame at its own size even when the next size is already in flight, so it no longer misses drag frames.

Written for commit 5b4fdce. Summary will update on new commits.

View guided diff Turn on auto-fix

…s window, the newest (Ghostty's min_ms rule)

T-KEYS (frame-owner-s4, STATUS 13889): a key resize or divider drag changes a
terminal's fit grid on almost every frame, and each report resized the PTY and
sent SIGWINCH. The coalescer follows Ghostty.app's src/termio/Thread.zig
(min_ms 25): the first new grid opens a 25 ms DemandTimer window, later grids
replace the waiting one, the fire sends the newest once; a grid equal to the
last sent one sends nothing. flush() ends the window at once (gesture end),
reportNow(_:) is the forced report, cancel() drops the waiting grid.
Not wired yet: the T-KEYS key-path change calls it from the grid-report path.

Tests: TerminalGridReportCoalescerTests (manual clock), run on the fleet.
(cherry picked from commit 449475f)
… sends to the newest grid in order (no Duration-keyed dictionary)

(cherry picked from commit 47bbe04)
…rag moves, not one at the landing (red)

cmux-ad m18/m29 (STATUS 14461): a full-screen program (alternate screen, not re-wrapped by the reflow preview)
stayed at its start size for the whole drag, the terminal background beside it, and jumped at the mouse-up:
the drag sends its one grid at the landing. DividerDragTerminalResizeCountTests now asks for Ghostty.app's rule:
during a 40-frame drag (12 ms apart) each PTY gets at least 2 grids and at most one per two frames, never the
same grid twice in a row, and rests at the grid its view fits.

Sidebar-Look: no visual change
… it moves, coalesced as Ghostty.app does

cmux-ad m18/m29 (STATUS 14461): a full-screen program (alternate screen, which the reflow preview does not
re-wrap) kept its start size for the whole drag, the terminal background beside it, and jumped at the
mouse-up: a drag held its grid report until the landing (`holding: ... || isFollowingResizeGesture`).

- TerminalGridReporter (new): every grid report of a terminal view goes through one
  TerminalGridReportCoalescer (#36553), so its last sent grid is the PTY's. A drag's grids are coalesced
  (the newest at most once per 25 ms, Ghostty.app's min_ms); every other report (at rest, forced, a
  spring's final grid) goes now.
- updateSurfaceSize: a followed drag, and its release's spring, no longer hold the grid.
- The drag's end, and a surface that stops following (it left its host or window), send the waiting grid now.
- The path is the same for split dividers and column or row edges: every DividerHandleView kind goes
  through ScreenContentView.resizeGestureMoved to the same followers.

Sidebar-Look: no visual change
… a commit showed (red)

frame-owner-s4's point 4 (the RESIZE OWNER's rule): in a drag the size asked of Ghostty runs up to two refreshes
ahead of the shown size, and the coalescer's newest can be a size that never shows. Display test with S4's rig:
a 40-frame drag paced 12 ms apart; every PTY grid must be the fit grid of a frame shown with its bounds, at least 2
during the drag, and the newest at rest the view's.

Sidebar-Look: no visual change
…n screen

frame-owner-s4's point 4 (the RESIZE OWNER's rule): a drag sent the grid of the size asked of Ghostty, up to two
refreshes ahead of the shown size; coalesced, the newest could be a size that never showed. A drag's grid now goes
when its size shows: at once when the bounds take it, else from the presenter's landing (frameDidLand, the one call
site keys can reuse) through TerminalGridReporter.shown(_:live:). A size that lands after the drag ended goes now.

Sidebar-Look: no visual change
…ecked per commit

frame-owner-s4's check: a drag step that asks a new size in the turn of a landing must send the landed grid. The
shown-grid test now checks, after each commit, that every PTY grid that arrived since is the fit grid of a frame some
commit already showed (before: any frame of the whole run, which a later ask would also meet).

Sidebar-Look: no visual change
… for its size to show

SPRINGS COALESCER's review: now() (a forced report, a spring's final grid, a report at rest) did not clear
awaitsShown, so a later frameDidLand could send an older grid after it. The PTY's last grid stays the newest.

Sidebar-Look: no visual change
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 5b4fdce: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr36631 -f ref=fix/m29-drags-coalesced-grid -f platform=macos -f backend_mode=local

web and size checks: green

Shipped in:

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent): SHIP at 0b0ad05

Independent read-only review subagent, started by the RESIZE PR WORKER (not by the author). It reviewed this PR's own 6 commits on top of #36553 (now merged as 1319d12). No blocking defect: the drag end flushes the waiting grid and the last grid sent is the landing grid; a drag grid waits for its frame to show (no draw-ahead grid); hidden/closed/moved surfaces are handled; everything is @MainActor with no retain cycle; other entrypoints keep their grid count and order.

Evidence:

  • Fleet DEV app build (compile) at this head: 76aa7e0b0a791cbc1702b534 done.
  • Fresh-home launch on that build: step 8960bd3a0d3b602c1f8e481c exit 0. The os-matrix launch smoke on macOS 14 and 15 runs now (RESIZE PR WORKER).
  • Test steps at review time: red 10980491 running; red display a20f5741 / 315142e1, green c43d8e49 (14 suites) and green display 1dcf0da6 queued.

ready-to-land waits for the macOS 14/15 smoke and the green step c43d8e49.

Non-blocking notes (follow-up candidates): TerminalSurfaceView+FrameOwner.swift:36 and :113 never resolve awaitsShown when the presenter cancels (switch toggled, or the view moves window mid-drag with a size in flight), so the PTY can keep an older grid until the next resize (resolve it in releaseFrameOwnership and in the cancel branch of place); TerminalGridReporter.swift:16 uses the default ContinuousClock, so the upper bound in DividerDragTerminalResizeCountTests depends on real 12 ms sleeps and can flake on a loaded mini (inject a manual clock); with motion.frameOwner.terminals off, drag grids go before their frame is drawn (debug-only switch).

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Launch smoke at this head (0b0ad05): os-matrix step 0c01a9c824bb660d6d7e7208 on build 76aa7e0b0a791cbc1702b534: PASS on macOS 14.8.7 and 15.7.7, 0 crashes, 0 hangs (RESIZE PR WORKER). ready-to-land now waits only for the green test step c43d8e49.

@teamleaderleo teamleaderleo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review (codex gpt-6-astra high, next-review, since=20261105T164400Z): SHIP at cc8adff

SHIP at cc8adff

Visual verdict: no images; Leo tries the build

  1. [P2] [follow-up] [category:correctness] [finding:F1] [outcome:pending] In renderer-owned mode, frameOwnership.inFlight is always nil, so TerminalSurfaceView.swift:482 treats every drag resize as already shown. Ghostty can still take 1–4 refreshes to render that size, while the 25 ms coalescer may send the PTY grid first. This violates the stated “only a committed on-screen size” rule; the new shown-grid test forces frame ownership on and does not cover this mode.

Follow-ups:

  • F1: Defer renderer-owned drag reports until the corresponding Ghostty frame is displayed, or cover and document the mode’s immediate-size guarantee.

…ame shown at its own size

Display runs 3c8a6bb8 (head) and 78cf9923 (red ref) both failed with 36-46 grids 'never shown': the test recorded a
shown size only when no size was in flight (GridSample.showsItsOwnFrame), but a drag asks its next size in the same
pass as the landing, so almost no drag frame was recorded. It now records a frame when the committed layer and the
frame it presents have one pixel size, whatever is in flight.

Sidebar-Look: no visual change
lawrencecchen added a commit that referenced this pull request Nov 5, 2026
…'s held grid; keep feat's stale in-flight guard

Decision (resize owner and lead, via frame-owner-s4): #36631 (frame-owner-s2) owns the drag grid path. In
updateSurfaceSize, `live` is the host following its pane's key hold only (no `|| isFollowingResizeGesture`); `holding:`
keeps `|| isFollowingResizeGesture`, so a drag's grid is held to the release as on feat. Feat's `stale` term is back in
`holding:` (review F1 of #35470: an in-flight size that is not the wanted size sends no grid). The grid here is the
committed bounds' (shownGrid), so the in-flight size's grid cannot be sent; the guard is kept anyway, as asked when not
sure. No other drag code changes (followResizeGesture, isHeldGridPending, stopFollowingResizeGesture untouched).

Not run yet: the 4 display suites at this head, next.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent): SHIP at 5b4fdce

Delta review by the same independent read-only review subagent, started by the RESIZE PR WORKER (not by the author). The earlier SHIP at 0b0ad05 no longer counts after the push. This review covers the two new commits: cc8adff (merge of feat-cmux-next, which now has #36553 and #36485) and 5b4fdce (test-only change to TerminalDragShownGridTests.swift).

  • The PR's own source changes are byte-identical to the reviewed head 0b0ad05 (checked by the RESIZE PR WORKER per file, and by the reviewer by hash of the changed lines). TerminalSurfaceView.swift differs only by perf(layout): pane content takes its pointer from the screen's one tracking area; a moving pane costs AppKit no cursor-rect walk #36485's one line, syncPointerTracking() in viewDidMoveToWindow, which does no size, frame or grid work. No PR hunk was dropped, no call is lost, and no report is sent twice.
  • The test change is not weaker. A shown grid is now read from the presented frame (layer.contents) right after a commit. An in-flight or drawn-ahead size cannot reach layer.contents before the presenter's pass, and a stretched frame fails the new pixel-size equality. The old inFlight == nil condition only dropped real on-screen samples. The test still fails when a grid reaches the PTY before any commit showed it.

Evidence (verified by the RESIZE PR WORKER):

  • 14 suites at cc8adff (same source): step 8fc860667acd exit 0.
  • Display green at this head: step d6d71b43e7dd exit 0.
  • Display red at 7d4c62e: step 9e5afccfbbc7 failed (exit 1), as expected.
  • DEV build at this head: ef8aa4c413266d832dbe80cf. The launch smoke on macOS 14 and 15 runs on it now. ready-to-land waits for that smoke.

Non-blocking notes (follow-up, already planned by the owner): a presenter cancel leaves awaitsShown set; TerminalGridReporter uses its own ContinuousClock, so the upper bound in DividerDragTerminalResizeCountTests depends on real sleeps; both tests check PTY grids only at commit boundaries.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Launch smoke at this head (5b4fdce): os-matrix step 0f39ec8e81cc961a26c46397 on build ef8aa4c413266d832dbe80cf: PASS on macOS 14.8.7 and 15.7.7, 0 crashes, 0 hangs (RESIZE PR WORKER). With the SHIP receipt at this head, the PR is ready-to-land.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 5, 2026
@lawrencecchen
lawrencecchen merged commit 9511b2d into feat-cmux-next Nov 5, 2026
242 of 248 checks passed
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Merged by the lead under the temporary m26 rule (compile + launch): fleet Release compile passed, the macOS 14/15 launch smoke passed at this head, and the review receipt says SHIP. The only red, cmux-next generated files, is a base red: the action catalog tests (ActionContractTests, ActionCatalogTests, ActionSurfaceParityTests) fail on action counts, and this PR changes no action file. The MERGE QUEUE agent owns that red.

@lawrencecchen
lawrencecchen deleted the fix/m29-drags-coalesced-grid branch November 5, 2026 23:47
lawrencecchen added a commit that referenced this pull request Nov 6, 2026
…9 drags: TerminalGridReporter)

Resolved on feat's design (frame-owner-s4 order):
- Every grid report goes through feat's TerminalGridReporter (`gridReports`). This branch's own coalescer property,
  sendGridReport and flushGridReport are gone; callers use gridReports.flush() and gridReports.coalescer.
- updateSurfaceSize: the PTY grid stays the committed bounds' (shownGrid). `live` = the host follows its key hold, or a
  drag (feat now sends drag grids while it moves). holding: feat's `stale || (!forceReport && isInPaneMotion && !live)`.
  Forced or at rest: gridReports.now. A drag: feat's gridReports.drag(_:shownNow:). A key spring: gridReports.coalesced.
- frameDidLand: updateSurfaceSize (the landed grid), then feat's gridReports.shown(fittedGrid, live:).
- TerminalGridReporter (frame-owner-s2's file): only the B3 `ticks:` pass-through to its coalescer.
- The presenter's cancel sites (place()'s hidden or windowless branch, releaseFrameOwnership) are unchanged, for
  s2's #36912 shownCancelled.
- Test callers of the removed names (KeyResizeFirstTickPinTests, PaneContentPolicyTests, TerminalHiddenResizeTests) use
  gridReports. `git grep` of ownFrames, releaseFrames, framesOwnedByCommit, motion.frameOwner.terminals,
  gridReportCoalescer, sendGridReport, flushGridReport and keepLimit: no code hit left in this package (comments, the
  browser's own BrowserResizeTunables.framesOwnedByCommit, and the bench script's generic --ab test data only).
lawrencecchen added a commit that referenced this pull request Nov 6, 2026
…; drag test on a manual clock (#36912)

* test(next): m29 drags: the drag grid test runs on a manual clock, with no real sleep

Review of #36631 (note 2): DividerDragTerminalResizeCountTests slept 12 ms per drag frame in real time so
the 25 ms coalescing windows could close; a loaded runner stretches those sleeps, and the test depended on
them. TerminalGridReporter now takes the coalescer's clock (ContinuousClock by default); the test gives each
terminal's reporter one ManualClock and advances it 12 ms per drag frame.

No product behavior changes (the default clock is the one the coalescer used before), so there is no
behavioral red for this commit; the drag test's own red/green stands (#36631: 10980491 red, 8fc86066 green).

Sidebar-Look: no visual change

* test(next): m29 drags: a drag grid whose size the presenter cancels still reaches the program (red)

Review of #36631 (note 1): a drag's grid for a size in flight waits until the frame of that size lands. When
the frame presenter cancels the size instead (the terminal hidden, or leaving its window, before the frame
lands), the grid waited for good: the view had already recorded it as reported, so the program kept an older
grid until the next resize. Display test: p1's host is hidden in a drag frame where its size is in flight, the
drag ends there; shown again and settled, p1's program must have the grid p1 fits.

Sidebar-Look: no visual change

* fix(next): m29 drags: a drag grid whose size the presenter cancels goes to the program

Review of #36631 (note 1): TerminalGridReporter kept only a flag for a drag grid waiting for its size to
show. When the frame presenter cancelled that size (the place() path for a hidden or windowless view, and
releaseFrameOwnership on a window change), the flag stayed, and the view's geometry already counted the grid
as reported, so the program kept an older grid until the next resize.

The reporter now keeps the waiting grid itself (awaitingShown). Both cancel sites call
shownCancelled(live:): the grid goes, coalesced while the drag is followed, else now. Nothing shows that
size in either case (the view is hidden or out of its window). A grid sent now still drops a waiting one.
TerminalSurfaceView's line count stays at its base (god-type rule): two doc lines are shorter.

Sidebar-Look: no visual change
lawrencecchen added a commit that referenced this pull request Nov 6, 2026
…size in every frame (drawn ahead, never stretched); PTY size coalesced 25 ms; no key hold; switch deleted (#36478)

* test(next): S4 m14 (c1): a low-prompt terminal draws the grid its pane fits in every frame of a key spring (red)

cmux-ad m14 with motion.frameOwner.terminals on: in every frame each terminal
shows pixels drawn at its pane's size, with the prompt in view. Two failures
remain for a terminal whose prompt is low (pixel-perfect films jjk-3kz):
- F2: a pane a press grows keeps its surface at the final size on its bottom
  edge (the low prompt's bottom hold), taller than its pane: its top rows are cut;
- H3: a pane that shrinks draws the owner's grid from its top-left (the reflow
  preview is off while the final grid does not fit), so its prompt is cut until
  the owner announces the new grid.

TerminalKeyResizeFrameTests (display): p1 over p2 with low prompts and an IO
that announces each grid it is asked for, as the daemon does (MANUAL_MIRROR).
J, K, and J J K on p1. After each frame's sizes land: each surface has its
area's frame, and the grid Ghostty drew is the grid the shown frame fits.

Switch-on expectations of F2's hold change to m14's (the surface has its
area's frame and holds nothing):
- PaneContentPolicyTests: aPromptOnTheLastRowRidesTheBottomOfAPaneAKeyGrowsDown
  (now ...StaysInViewInAPaneAKeyGrowsDown; the switch-off arm is unchanged),
  aLowPromptBottomHoldAfterAPressOnTheOtherAxisKeepsItsPanesWidth (now
  aLowPromptPressOnTheOtherAxisKeepsTheSurfaceOnItsArea),
  aLowPromptBottomHoldNeverLeavesABandAboveTheTerminal (now
  aLowPromptGrowDownTwiceThenUpKeepsTheSurfaceOnItsArea), and the switch-on arm
  of aReversePressAfterALowPromptGrowsDownFollowsItsPane;
- TerminalSurfaceCommitSizeTests: aKeptFrameOfALowPromptHoldNotTallerThanItsAreaStaysTopLeft
  (now aFollowedSurfaceTakesItsAreasFrameWhateverItsHold): a hold taller than
  the area no longer moves the surface to the bottom edge.

* fix(next): S4 m14 (c1): with the switch on, a key spring draws each terminal's reflow preview at its pane's grid; no low-prompt bottom hold

cmux-ad m14 (switch motion.frameOwner.terminals on): in every frame each
terminal shows pixels drawn at its pane's size, with its prompt in view.

F2: restAt held a low prompt's surface at the press's final size on its
pane's bottom edge (#35470, #35914): taller than its pane, its top rows cut
for the whole spring. H3: TerminalGridPolicy.previewsViewGrid turned the
reflow preview off while a reported final grid did not fit the view (the old
m6 rule), so a shrinking pane drew the owner's grid from its top-left, its
prompt cut until the owner announced the new grid.

One decision now drives the frame, the hold and the preview:
TerminalHostView.surfaceFollowsArea (the host follows its pane through a key
hold with the switch on).
- followedSurfaceFrame gives the surface its area's frame, whatever hold it
  has; the low-prompt branch of #35914 had no key-spring use left and is gone.
- restAt holds nothing for a following host and lets go of an earlier hold
  (a join's), whatever the anchor (G1 released only for .top). The final grid
  still goes once per press (reportFinalGrid).
- updateReflowPreview passes it to previewsViewGrid(followingPane:), which
  keeps the preview on: Ghostty's ReflowPreview reflows a copy of the terminal
  to the view's grid with Terminal.resize, which keeps the cursor row in view.

Switch off, and splits, closes, zooms and divider releases (no following
host): unchanged. TerminalGridPolicyHoldTests covers the new parameter.

* test(next): S4 m14 (c1): a key press during a close spring follows only the panes that were at rest

The join question of c1, with motion.frameOwner.terminals on: a close spring
holds a terminal at its final size and a key press comes before it lands.
ScreenContentView.applyPresentation starts a key hold only for a pane not on a
layout spring, so the pane still on the close spring does not follow: its
surface keeps the close's hold (moved to the key press's final size) until the
spring lands. The pane at rest when the key came follows the key hold and has
its area's frame in every frame. Both rest at their areas after the landing.

* test(next): S4 m18 (T-KEYS): every committed turn of a key resize shows each terminal at its pane's size (red)

From draft #36158 (test/frame-owner-s4-m14-window-sync-red, a35e159 +
bd7ae3a's messages): cases 1-3 of TerminalWindowFrameSyncTests. The
fence/deadline case and its seam protocol are dropped (the fence is off
m18's path).

m18 (Lead, STATUS 13889): no keep and no main-thread wait, so a frame
Ghostty has not drawn by its commit lands late: its new bounds go in that
commit with the last frame, top-left. The presenter now counts those
(Stats.lateLandings, lateLandings(of:), isLate(_:); at the base only a
keep-limit fallback lands late). Check (a), the layer is its pane's area,
holds in every committed turn; check (b), the shown frame is the layer's
size, holds in every turn except while that terminal's frame is late;
each case prints the late turns and the longest late streak (ms and 120 Hz
refreshes), and every terminal must show its own frame at rest. Cases 1
and 3 require a pass of the presenter (a new size went through it)
instead of a present or a waiting turn: with no keep both can be zero.

Red at this commit: (a) fails in the turns ready-or-keep keeps a
terminal's old bounds.

* test(next): S4 m18 (T-KEYS): key resizes follow the pane with the switch on or off, no grid at the press, no keep (red)

m18 (Lead, STATUS 13889) replaces m6's 'pixels per frame, program size per
press' and ready-or-keep:
- KeyboardResizeContentHoldTests: the terminal case no longer expects one
  grid per terminal at the press; it expects no grid in the key's turn and
  each PTY's last grid = its view's grid at rest (the coalesced reports end
  at the landing). The switch-off case (one surface size per press) becomes
  'the producer follows its pane with the switch off too': m18 deletes the
  key hold in both states, the switch only picks who presents.
- KeyboardResizeReflowAfterLandingTests: 'final grid by the first frame
  tick' becomes 'no grid by the first frame tick, PTY ends at the view's
  grid'; the switch-off case follows its pane.
- TabStripFollowsPaneTickTests (terminal case): the surface is on its area
  in every frame in both switch states (it took one size per press off).
- TerminalSurfaceCommitSizeTests: a new size's bounds are in the commit of
  its layout (never kept); a turn whose frame is not the layer's size is
  allowed only while the presenter has that frame late; the kept-frame
  test becomes 'a followed surface takes its new area in the turn it
  changes'; fallback counts become late landings.
- TerminalKeyResizeFrameTests.showsItsOwnFrame reads the presenter instead
  of the deleted in-flight size.

Kept as they are: KeyboardResizeContentHoldTests lines 34-71 and
TabStripFollowsPaneTickTests 195-216 test generic held content (pages,
chats: PaneHostView's K3 hold, B-PAGES under m18), not terminals.

* fix(next): S4 m18 (T-KEYS): key resizes give each terminal its pane's size in every frame; no key hold, no keep; switch on

cmux-ad m18 (Lead, STATUS 13889).
- No key hold: TerminalHostView.surfaceFollowsArea no longer reads the
  switch. A host that follows its pane through a key resize gives its
  surface the area in every frame with motion.frameOwner.terminals on or
  off (off = the kill switch: Ghostty's renderer presents). restAt for a
  following host reports no final grid and holds nothing; the view's grid
  goes to the PTY as for any spring (held to the landing here; coalesced in
  the next commit). Split, close and zoom springs keep their final grid at
  the start and their final-size hold.
- No keep: TerminalSurfaceView.place(_:) sets the view's frame at once (any
  layout, AppKit's display cycle included), so ghostty_surface_set_size gets
  the pane's pixel size in that turn and the reflow preview re-wraps the
  text at it. The presenter's pass presents a frame Ghostty drew by the
  pass with those bounds in the same commit; one not drawn yet is a late
  landing (Stats.lateLandings, isLate, lateLandings(of:)): the last frame
  shows top-left in the new bounds and the watch presents the new frame in
  the first commit after it is drawn. Deleted: keepLimit, the fallback, the
  keep counter, the in-flight and wanted sizes, keepOrigin and
  anchoredFrame, and the group present (groupTicks, groupWaits): with no
  keep every request lands in its first pass, so a group could only delay a
  ready frame. A held display scale goes to the layer with its frame or at
  the pass.
- TerminalDragTunables.framesOwnedByCommit defaults to true (kept as the
  kill switch). holdsSurfaceDuringDrag is frame-owner-s2's and unchanged.

Green for the red tests of the two commits before.

* feat(next): TerminalGridReportCoalescer: one PTY grid report per 25 ms window, the newest (Ghostty's min_ms rule)

T-KEYS (frame-owner-s4, STATUS 13889): a key resize or divider drag changes a
terminal's fit grid on almost every frame, and each report resized the PTY and
sent SIGWINCH. The coalescer follows Ghostty.app's src/termio/Thread.zig
(min_ms 25): the first new grid opens a 25 ms DemandTimer window, later grids
replace the waiting one, the fire sends the newest once; a grid equal to the
last sent one sends nothing. flush() ends the window at once (gesture end),
reportNow(_:) is the forced report, cancel() drops the waiting grid.
Not wired yet: the T-KEYS key-path change calls it from the grid-report path.

Tests: TerminalGridReportCoalescerTests (manual clock), run on the fleet.

* test(next): TerminalGridReportCoalescerTests: per-frame test compares sends to the newest grid in order (no Duration-keyed dictionary)

* fix(next): S4 m18 (T-KEYS): a key resize's PTY size goes coalesced, 25 ms, ending at the landing's grid

Wires TerminalGridReportCoalescer (the two commits before, from
fix/frame-owner-s4-t-keys-coalescer) into the one grid report path,
TerminalSurfaceView.updateSurfaceSize:
- live (the host follows its pane through a key resize, or
  isFollowingResizeGesture): viewSized(grid), the first change opens a
  25 ms window and the newest goes when it ends (Ghostty.app Thread.zig
  min_ms). The view's grid is no longer held to the landing for a key
  resize; a split's, close's or zoom's spring still holds it.
- forced (a new owner): reportNow. At rest: viewSized then flush, so the
  report goes now unless the PTY already has it.
- endPaneMotion flushes: the landing's grid goes at the landing.
- reportFinalGrid (split, close) goes through reportNow too, so the
  coalescer's last-sent grid is always the PTY's.
- sendGridReport(_:) reads the cell size when it sends; gridReports counts
  sends. isGridReportPending (held or in a window) settles
  TerminalSizeSample. The reflow preview stays on while a grid waits.
Drags: frame-owner-s2's drag PR drops isFollowingResizeGesture from the
holding: argument; its reports then go through the same coalescer with no
call of its own.

Tests of m6's 'final grid by the press's first frame' move with it:
PaneContentPolicyTests.grids flushes the window before it reads (a waiting
grid counts as asked for); the held-key cases allow one grid per read plus
the landing, last = final; the press-and-reverse case ends at the start
grid; TerminalKeyResizeFrameTests bounds the grids per terminal by the
25 ms windows of the press and checks each PTY ends at its view's grid.

* test(next): S4 m18 (T-KEYS): PaneContentPolicyTests run each key-resize case in both switch states, to the m18 contract

m18 deletes the key hold in both states of motion.frameOwner.terminals and
sends the view's grid coalesced (25 ms) instead of one final grid at the
press. Each test below asserted that removed behavior; each is rewritten
(none dropped) so it fails if the hold or the press-time grid comes back:
- aKeyPressShowsEachSurfaceAtItsPanesSizeAndSendsOneGrid ->
  ...AndItsPTYEndsAtItsFinalGrid(direction:switchOn:): now also runs with
  the switch off; no grid in the key's turn; PTY ends at the final grid.
  Replaces aKeyPressSizesEachSurfaceOnceWithTheSwitchOff (asserted one
  surface size per press, the key hold).
- aHeldKeyShowsEachSurfaceAtItsPanesSizeAndSendsAtMostOneGridPerPress ->
  ...AndItsPTYEndsAtTheFinalGrid(switchOn:): one grid per read plus the
  landing, last = final. Replaces aHeldKeySizesEachSurfaceOnlyInAPressTurn
  WithTheSwitchOff (surface sized only in press turns: the key hold).
- aPressAndItsReverseInOneHoldSendEachGridInItsTurn ->
  aPressAndItsReverseInOneHoldEndsAtTheStartGrid(switchOn:): surface on its
  pane every frame, rests at the start size, PTY ends at the start grid.
  Replaces aPressAndItsReverseRestsAtTheStartSizeWithTheSwitchOff.
- aParkedTerminalShownDuringAKeyHoldFollowsFromItsFirstFrame: the switch-
  off arm now requires the area's size in every frame (was <= 1 new size).
- aPromptOnTheLastRowStaysInViewInAPaneAKeyGrowsDown and
  aReversePressAfterALowPromptGrowsDownFollowsItsPane: the switch-off arm
  now requires no bottom hold (was: the hold must exist).
- aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress: each
  read gets at most one grid, the last is the final grid, none after it
  (was: the second press's final grid at its first frame, none at landing).
- aLowPromptPressOnTheOtherAxisKeepsTheSurfaceOnItsArea and
  aLowPromptGrowDownTwiceThenUpKeepsTheSurfaceOnItsArea: a setup check
  that the prompt is in the lower half (review N2 of #36199).
- grids(_:_:) ends the coalescer's window before it reads.

* test(next): S4 m18 (T-KEYS): keep PaneContentPolicyTests.grids, which the switch-off section removal took with it

00471ab removed the ...WithTheSwitchOff section and the grids(_:_:)
helper that sat inside it; the other suites call it (compile error at
step 2dfd615d48e54adee09a2e9d). Restored unchanged (it flushes the
coalescer's window before it reads).

* test(next): S4 m18 (T-KEYS): two 'one grid per press' tests move to the 25 ms coalescer rule; a tab shown mid key hold follows from its first frame

m18 replaces 'one grid per press' with TerminalGridReportCoalescer's rule
(at most one grid per 25 ms window of the press plus the landing's flush;
the last grid is the view's grid at rest):
- TerminalKeyResizeFrameTests.aTerminalWhoseFrameWaitsAtTheLandingGetsOne
  GridForThePress -> aTerminalWhoseFrameIsLateAtTheLandingEndsAtTheLandings
  Grid: the mode-2026 terminal's frame is waiting or late at the landing
  (setup check, read after the frame's commit), grids bounded by the 25 ms
  windows, PTY ends at the view's grid (step f5645d6c failed its setup
  require: with no keep nothing waits).
- PaneContentPolicyTests.aTerminalThatJoinsAKeyHoldGetsOneGridPerPress
  ThroughASecondPress: grids bounded by the 25 ms windows from the press,
  last = final, none after it; the reads no longer flush the window.
Code (measured at step d8948371, 2 grids in one read): a parked tab shown
mid key hold joined (restAt) before its area handed it the follow, so it
sent a final grid outside the coalescer and then coalesced ones.
TerminalHostView.joinsAreaKeyHold: restAt treats it as following.

* fix(next): S4 m26 (T-KEYS): bounds change only with their drawn frame; key springs draw the next refresh's size ahead; motion.frameOwner.terminals deleted

Measured at 10abcfd (step f5645d6c): c102749 removed the keep and
let the layer take its new bounds before Ghostty drew that size, so the old
frame showed stretched in the new bounds (TerminalKeyResizeFrameTests:101
"frame 1 p2: layer 547x572, frame 549x572", TerminalSurfaceCommitSizeTests
:48, :217, :220). The resize owner chose draw ahead (m26); a synchronous
main-thread draw is refused.

The contract, the Chromium model (the browser compositor shows a new size
only with a frame the renderer submitted for it, and the renderer gets the
size before the compositor needs its frame):
1. A terminal's bounds change only in the commit that presents a frame
   drawn at those bounds. TerminalFramePresenter's ready-or-keep is back
   (feat's pass at order 1_999_999, the group present included: with keeps
   a group lands its two terminals together). Its keepLimit fallback, which
   set the bounds without the frame (a stretch), is deleted; long keeps are
   counted as data (Stats.longKeeps, more than 2 refreshes).
2. Key springs draw ahead: ScreenContentView.step steps a copy of each
   moving pane's spring once more, by MotionClock.nextFrameInterval (the
   display link's refresh period: 8.33 ms at 120 Hz, 16.7 ms at 60 Hz), and
   hands the size change down PaneHostView -> PaneContentAreaView ->
   PaneMotionHoldFollowing.drawAhead(nextHostSize:) -> TerminalHostView.
   After this turn's pass the surface gives Ghostty that size, render only
   (TerminalSurfaceView.applyAhead: no grid report; the reflow preview
   re-wraps at it); one whose frame still waits gets it when that frame
   lands. The next refresh's place() finds the size drawn ahead, and the
   pass presents it with its bounds. An ahead size no layout takes within 2
   refreshes goes back (dropAhead). set_size is not repeated at the size
   Ghostty already has (it would drop the drawn frame).
3. A divider drag, a Reduce Motion jump and hidden panes draw nothing ahead:
   the last bounds and frame stay, clipped or beside the terminal ground,
   until the new frame is drawn.
4. The pass never draws and never waits. Off-main presents of a frame drawn
   ahead cannot reach the old bounds: the fork's offMainDecisionLocked
   routes a frame whose size differs from the shown frame through main
   (IOSurfaceLayer.zig:292, pin 12ec155e1), and embedderDrops drops it there
   while the bounds do not have its size.

motion.frameOwner.terminals is deleted (m19/m21: no switches): every
surface presents through its window's commit from creation
(ownFramesByCommit); the renderer-present path and the switch-off key hold
are gone. holdsSurfaceDuringDrag is unchanged (frame-owner-s2's; #36311
set it off). No code or test writes the key any more, which also removes
the cross-suite TunableStore reset (step f933b493).

Tests:
- TerminalWindowFrameSyncTests: (b) strict in every committed turn (no
  stretch, no excuse); case 2 paces one refresh per spring frame with
  output in both terminals and requires (a) from the spring's second frame
  on (frames kept = 0 after the first commit) and drawnAhead > 0; case 1
  (Reduce Motion jump, rule 3) allows a keep for 2 refreshes.
- TerminalSurfaceCommitSizeTests back to feat's assertions; fallback
  counts become longKeeps (data).
- Switch-off cases removed with the path:
  KeyboardResizeContentHoldTests.theProducerFollowsItsPaneWithTheSwitchOffToo,
  KeyboardResizeReflowAfterLandingTests.theProducerFollowsItsPaneWithTheSwitchOffToo(direction:),
  and the switchOn: false arm of PaneContentPolicyTests (aKeyPressShows...,
  aHeldKeyShows..., aPressAndItsReverse..., aParkedTerminalShown...,
  aPromptOnTheLastRow..., aReversePressAfterALowPrompt...,
  aTerminalThatJoinsAKeyHold...), PaneGroundCoversPaneTests (2 cases),
  KeyResizeFirstTickPinTests (2), TabStripFollowsPaneTickTests (1) and
  TerminalLaunchFrameKeeperTests (2). Their switch-on assertions stay.
  PaneGroundCoversPaneTests.ownFrames/releaseFrames are deleted.

* test(next): S4 m26 (T-KEYS): a drawn-ahead frame is held, not discarded; a key spring's last frames and landing show their own frames with the new text

Resize owner's two tests (TerminalWindowFrameSyncTests):
- Case 2 (paced key spring, output every frame): no committed turn after
  the spring's first frame has a terminal waiting for its frame. A frame
  drawn at S(n+1) in refresh n must be the one commit n+1 presents with
  bounds S(n+1); a discarded ahead frame makes every spring frame wait.
- Case 4 (spring end): the last 3 spring frames and the turns up to one
  refresh after the landing show each terminal's own frame at its bounds
  and its pane's area, with nothing waiting; output written after the
  landing shows in a newer frame of the rest size.

The fork holds the frame (Metal.zig embedderShowsKeptFrame keeps it in
'fresh'; hostFrame(show:) presents it with presentFresh at the host size);
only a host-size change drops a kept frame, even back to an equal size,
which setSurfacePixels never makes. Red evidence: these run at a
no-draw-ahead copy of this head (fix/frame-owner-s4-t-keys-noahead-nomerge,
applyAhead returns at once).

* test(next): S4 m26 (T-KEYS): keep PaneGroundCoversPaneTests.aCloseLeavesNoHoleWhereThePaneWas's columns arguments

372ff9e's switch-off cleanup removed '@test(arguments: [false, true])'
from every test, also this one, whose parameter is columns:, not
switchOn: (compile error at step f3a717ae).

* test(next): S4 m26 (T-KEYS): a held key resize gives the PTY only grids its pane showed, at most every 25 ms (red)

Resize owner's film finding (STATUS 14461): the PTY must get only sizes the
pane SHOWED, through the 25 ms coalescer. At b6a7e1e updateSurfaceSize
reads the grid from ghostty_surface_size after set_size(inFlight ?? ahead
?? bounds), so a call while a size is drawn ahead (the owner's announcement
arriving between the pass and the next frame) puts the ahead grid into
geometry and the coalescer: a grid the pane may never show (a press that
retargets the spring).

TerminalWindowFrameSyncTests case 5,
aHeldKeyResizeGivesThePTYOnlyGridsThePaneShowedAtMostEvery25ms: 3 presses 4
frames apart, paced one refresh per frame, output in both terminals, an
owner that announces each grid (AnnouncingTerminalIO now records each
report and its main-thread time in resizeOnMain). Checks (a) in every
committed turn the view's grid (fittedGrid) is the grid its committed layer
bounds fit; (b) every PTY grid is one a committed turn showed; (c) grids at
least 25 ms apart before the landing; (d) a changed shown grid reaches the
PTY within 25 ms + 2 refreshes + 8 ms; (e) the last grid is the rest grid.
The recorder samples shownGrid and viewGrid per terminal per turn; the
spring helper takes presses/framesBetweenPresses (default one press).

* fix(next): S4 m26 (T-KEYS): the PTY gets the grid its pane showed, never a size drawn ahead

updateSurfaceSize gave geometry and the coalescer the grid of
ghostty_surface_size after set_size(inFlight ?? ahead ?? bounds). A call
while a size was asked and not shown, or drawn ahead for the next refresh
(the owner's announcement arriving between the pass and the next frame),
sent that grid: one a press that retargets the spring may never show.

Now the grid comes from the view's committed bounds
(TerminalSurfaceView.shownGrid: ghostty_surface_fit_grid of their pixel
size, or ghostty_surface_size's grid when Ghostty has the bounds' size).
The presenter's landing (frameDidLand -> updateSurfaceSize) gives the
coalescer each shown grid as the bounds commit. The inFlight and ahead
sizes still go to set_size only (Ghostty's render, its reflow preview).
The model's grid follows the same shown grid. Drags are unchanged (the
holding: rule stays; frame-owner-s2's PR moves drags to the coalescer).

Green for TerminalWindowFrameSyncTests case 5 (11324e0, red step
19cd7b71).

* fix(terminal): keep a drawn-ahead size until its hold ends; end grid-report windows at the first refresh at or after 25 ms

B1 (diag bf2b674a): the presenter dropped a size drawn ahead after 2 display ticks (8.3 ms each) with no layout. A
spring step can come 16.6 ms later, so the drop set Ghostty back to the bounds, threw away the frame drawn for the next
refresh, and that refresh waited for its frame again (trace +1183.36 applyAhead, +1199.51 set_size, +1200.13 pass
aheadHit false). The tick timeout is deleted. A size drawn ahead goes only when its followed hold ends
(followPaneMotion(nil)) and the final placement did not take it (TerminalHostView placeSurface). A retarget miss keeps
today's path (place asks for the wanted size).

B3 (diag bf2b674a): the coalescer's 25 ms DemandTimer fired up to 124 ms late on a loaded runner; the sizes reached it
on every frame. An open window is now also ticked by its view window's presenter (TerminalGridReportTicks, injected):
it ends at the first refresh at or after 25 ms. The timer stays armed as the backstop (no frames run, or frames stepped
by hand as in frame-owner-s2's drag tests); whichever comes first sends the grid, once. Public API unchanged
(viewSized, flush, reportNow, cancel).

The view cancels a waiting grid when it is hidden, its session closes, or a surface swap detaches it, and then reports
its grid (forced) when shown again, so the PTY never keeps a grid only the dropped window had.

Tests: TerminalGridReportCoalescerTests (7) a window ends at the first refresh at or after 25 ms with a late timer,
(8) no refresh: the timer ends it, once, (9) refresh and timer for one window: one send. ManualClock, no wall clock.
Not run yet: on the fleet next (display suites of 5dcf9835 at this head).

* docs(terminal): drop comments that still name the deleted motion.frameOwner.terminals switch

Comments only (frame-owner-s4 review of #36478): TerminalPaneFollow, TerminalSurfaceView+PaneMotion,
KeyResizeFirstTickPinTests, PaneContentPolicyTests, TerminalWindowFrameSyncTests, TerminalKeyResizeFrameTests and
bench-terminal-resize.py. No assertion changes.

* T-KEYS: resend a dropped grid only if the PTY lacks it; B2 commit timing; B4 drawn grid read at the present

- cancelGridReport (frame-owner-s4 review): a hide marks a resend only when the dropped window's grid is not the PTY's
  (lastSent). Shown again, the view's grid goes as a grid at rest (viewSized + flush, which skips the PTY's grid), not a
  forced report: a hide and show with nothing dropped sends nothing (no PTY resize per tab switch). Test
  TerminalHiddenResizeTests: hide during a 25 ms window, show at the same size: one send, the newest grid; hide and show
  with no change: 0 sends.
- B2 (test support only, no assertion change): TurnRecorder splits each awake turn into before-commit and commit spans
  (an observer just before the presenter's pass), each with its off-CPU time, plus the task's context switches. A
  main-thread violation message now names where the blocked time went.
- B4 option (ii) (resize owner): TerminalFramePresenter.presentedHook (DEBUG/bench builds) runs in the pass right after a
  request's frame becomes the layer's contents, before the turn's draw-ahead sizes go to Ghostty. The low-prompt test
  reads Ghostty's drawn grid there, checks in the same pass that the frame read has the pixel size the pass gave the
  layer, and samples each frame from the record of the frame on screen. The grid-fit assertion is unchanged.

Not run yet: fleet steps next.

* T-KEYS: #36478 wires only key springs live; a divider drag keeps feat's held grid; keep feat's stale in-flight guard

Decision (resize owner and lead, via frame-owner-s4): #36631 (frame-owner-s2) owns the drag grid path. In
updateSurfaceSize, `live` is the host following its pane's key hold only (no `|| isFollowingResizeGesture`); `holding:`
keeps `|| isFollowingResizeGesture`, so a drag's grid is held to the release as on feat. Feat's `stale` term is back in
`holding:` (review F1 of #35470: an in-flight size that is not the wanted size sends no grid). The grid here is the
committed bounds' (shownGrid), so the in-flight size's grid cannot be sent; the guard is kept anyway, as asked when not
sure. No other drag code changes (followResizeGesture, isHeldGridPending, stopFollowingResizeGesture untouched).

Not run yet: the 4 display suites at this head, next.

* test(next): PaneMotionPointerAreasTests: drop the deleted motion.frameOwner.terminals off arm (compile red at 3d656a1)

PaneMotionPointerAreasTests (#36485, from feat in 21f0fe8) called PaneGroundCoversPaneTests.ownFrames/releaseFrames,
which this branch deleted with the switch (cmux-ad m19). Step 2553c18b: 'type PaneGroundCoversPaneTests has no member
ownFrames'. The case now runs once, frames owned by the commit (the former true arm); its assertions are unchanged.

* test(next): T-KEYS: a grid a hidden view keeps for its show never goes after a newer grid (frame-owner-s2's rule)

Point 2 of frame-owner-s2's check on 5b3c0af. A hide drops a waiting grid and keeps one for the show; then a
spring's final grid, or a forced report, goes while hidden; then the view shows. The PTY's last grid must be the view's
fit, and the show sends at most one grid.

No red: the code already meets the rule. Step 27ff8556 (non-display, 5b3c0af + this test, without the proposed
124b857535e) passed both arms: final arm hidden [40x12], shown [86x27], fit 86x27; forced arm hidden [], shown [86x27].
The show's resend sends fittedGrid after the show's own updateSurfaceSize, so it is never an older grid; a forced report
does not go while hidden. 124b857535e is dropped; this test stays as a guard.

* test(next): T-KEYS review of 5840aea: draw-ahead at the app's cadence, a hidden grid in a spring, hide/show in a spring (red)

- P1 #1 TerminalWindowFrameSyncTests.atTheAppsCadenceAKeySpringShowsEachTerminalAtItsPaneSizeFromItsThirdFrame: one
  commit per refresh after the step and its layout, then a sleep to the next refresh with no extra main turns; check (a)
  from the spring's third frame. The paced case's extra commits hid a draw-ahead that stops after one late frame.
- P1 #2 TerminalHiddenResizeTests.aGridAHideDropsReachesThePTYWhenTheViewShowsDuringASpringAndLands: G1 waits during a
  key spring (PTY at G0), hide, show while on the spring, land: the PTY must end at G1.
- P2 #3 TerminalWindowFrameSyncTests.aHideAndShowInsideAKeySpringNeverShowsAFrameDrawnAheadInTheOldBounds: p2 hidden
  and shown inside a key spring; check (b) in every committed turn.
Red on the fleet at this commit (never-merge branch) before the fix.

* fix(next): T-KEYS review of 5840aea: draw-ahead survives a late frame, a hidden grid reaches the PTY, a hide drops the size drawn ahead

- P1 #1: a frame that landed late in a key spring asked for the size wanted now before this turn's size drawn ahead
  went to Ghostty (`landed` -> `requestWantedFrame` before `applyAheads`), so every later frame was drawn a refresh
  late. Now the landing leaves the next refresh's size to `applyAheads` (`drawsAheadAfterLanding`); the next layout
  takes it, as Chromium gives the renderer the size before the compositor needs its frame.
- P1 #2: a grid a hide dropped (`cancelGridReport`) was lost when the view showed inside a key spring: the show's
  resend ran only at rest and cleared its flag. `resendShownGrid(atRest:)` sends it at rest, coalesced in a key
  spring or a drag, else at the landing (`endPaneMotion`, a drag's end).
- P2 #3: a hide kept the size drawn ahead, so the show's present showed that frame in the old bounds. A hide drops it
  (`dropAheadForHide`); shown, Ghostty gets the view's size back.
- TerminalDragShownGridTests (fleet 990efb42, 2 of 38 grids "before any commit showed it"): the test sampled shown
  sizes only after the commits it drove; sizes the presenter's own refresh turns showed during the drag's 12 ms pace
  were missed. It now also records each present in the pass (`presentedHook`), with the bounds the pass gave it.

* test(next): T-KEYS P1 #1 made deterministic: a late frame in a key spring costs its own refreshes, not the rest of the spring

The app-cadence case (b697e21) passed on the old product at fleet df16ac19 (no frame came late on that runner: keeps 0),
so it is no red for P1 #1. Case 2d holds both programs' frames with synchronized output (mode 2026, kept across a locked
grid's resize) for spring frames 2 to 5, so a frame lands late with a newer size wanted; check (a) from frame 11 on.

* test(next): T-KEYS: take out case 2d: mode 2026 held no frame on the fleet (no red)

Fleet c547fa88 at 153afb9 (old product): its SETUP failed, longKeeps 0: synchronized output did not hold the
frames of a resize, so no frame came late and the case proves nothing. Takes back aa792cc only (a test).

* fix(next): T-KEYS merge: the pane check counts long keeps, not fallbacks (S4 has none)

#37049 on feat reads TerminalFramePresenter.Stats.fallbacks, which S4 m18 (c102749) took out: S4 never
shows bounds without their frame. follow_presenter now reports long_keeps (passes that kept a late frame past
longKeepTicks refreshes). test_bench_terminal_resize passes locally (python unittest).

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 5b4fdce4 Deployed Nov 5, 2026 by lawrencecchen via cmux-tui tree preflight #117513
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants