Skip to content

Frame owner S4: terminals follow their pane in every frame of a key resize (switch-on path; default stays off) - #35470

Merged
lawrencecchen merged 35 commits into
feat-cmux-nextfrom
fix/frame-owner-s4-on
Nov 4, 2026
Merged

lawrencecchen merged 35 commits into
feat-cmux-nextfrom
fix/frame-owner-s4-on

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

FRAME OWNER REFACTOR stage S4 for terminals, the key-resize step (cmux-ad m6). #33956 landed the frame owner for terminals behind motion.frameOwner.terminals, off by default (feat e5ccc759105). This PR makes the switch-on path follow the pane in every frame of a keyboard resize, fixes the independent review's findings, changes the key-resize tests to the m6 contract, and keeps the default path's checks with the switch off. The switch stays off by default (lead's order): with it on, a held key resize with 7 or more moving terminals drops more refreshes than with it off (below). The flip goes in its own PR after the Ghostty fork change that cuts the per-frame cost, and only when that A/B is no worse with ON.

The m6 contract (lead). In every frame of a key resize, each terminal shows pixels at its pane's size in that frame, or the previous frame's pixels clipped and anchored top-left. The pixels are never stretched and never leave a background gap. The program's grid (the PTY size) changes at most once per press.

The defect. Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but browsers/terminals do not anymore". In a key resize, the terminal host follows its pane in every frame (FRAME OWNER S3). But the surface kept one size per press: restAt held it at the press's final size, or S3 held it at its start size, on the hold's still edges. So the content froze, then jumped. Film on 437f5a22 (px-keys-single-3k-s1r): the terminal right of the divider had its left ring on its pane's left edge in only 14 of 50 motion frames. It was off by -8 to +21 px in 20 frames and cut off in 16.

With the switch on:

  • TerminalHostView.followedSurfaceFrame gives the surface the area it shows in, in every frame.
  • The frame owner (S4, ready-or-keep) shows each size's frame together with its bounds. Until a size is drawn, it keeps the last frame, anchored top-left. One size is in flight at a time, and the newest size goes next.
  • Nothing draws or waits on the main thread. The main thread gives Ghostty the new size, and Ghostty draws it on its renderer thread.
  • restAt still reports the final grid at the press: that is the PTY's one resize. It no longer holds the surface at the final size.
  • The kept frame's anchor is the top-left again. An earlier commit in this branch placed the kept frame on the S3 hold's trailing or bottom edge (b0a6f600e45); it is reverted. Its test keeps its scenario and expects the top-left anchor.

With the switch off, the behavior is the same as before this PR (S3's producer hold on the still edges).

Fixes from the independent review at 665b1874e6e (BLOCK, comment 6635411077), red first:

  • F1: when a terminal's frame for a size was not drawn at the landing (a busy program, or one that holds its frames with synchronized output, mode 2026), the landing's release reported the grid of that stale size, and then the newest size reported the final grid again. One press sent three grids to the PTY. Now a size in flight that is no longer the wanted size reports no grid (TerminalSurfaceView.updateSurfaceSize, 30eccfc5903). Test aTerminalWhoseFrameWaitsAtTheLandingGetsOneGridForThePress (display): red at 1578c8aaed7 (= 712df02da95 + the test fix ec9992f5a2a), step cd3f57162b30f380684570f4: p1 got 61x33, 55x33, 61x33 for one press. Green at ec9992f5a2a, step e62615850f8eb172be13f40e.
  • F2: a pane that Ctrl-Shift-J grows down, with a prompt in its lower half, now keeps S3's bottom hold: the surface keeps its final size on the moving bottom edge, so the prompt stays in view. A split and a close already keep this rule. A surface at the pane's size showed the final grid's top rows, so the prompt was out of view for the whole spring (restAt and followedSurfaceFrame, 30eccfc5903). Test aPromptOnTheLastRowRidesTheBottomOfAPaneAKeyGrowsDown: red at 712df02da95, step 88c13d12f2e07855a8ca5037 ("the terminal kept its final size on 0 frames"); green at 30eccfc5903, step 59488e61824f720f9e84bf19.
  • F3 (a parked tab shown mid-spring): 5d62688a264 is reverted by cc2c26ec9a3. Its red test passed without it (step aef1ba3d3e128b7e7a5b8b01): the tab is unhidden in the same layout pass in which its area hands it the hold, so no frame shows the stale hold. The test stays as a guard.
  • T1: the key-resize display test also requires each terminal to take a new size in more than 3 spring frames (a terminal that never moved passed the size check).
  • G1 (the review at 447f784c3cf, BLOCK, comment 6639299648): with the switch on, restAt returned early for a followed host with a .top anchor and left an older surface hold until the landing. A J press with a low prompt then a K press kept the bottom hold through the reverse spring; a terminal that joined mid-hold then got a second press sent grid1, grid2, grid1, grid2. restAt now releases the hold first (5aab4968d19). Tests: aReversePressAfterALowPromptGrowsDownFollowsItsPane red at 7abac06b51d, step 1b0702f8dbf54ae7091c8dad ("24 frames showed a surface off its pane's size after the reverse press"); aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress passed there in its first form (a tab added new mid-hold hears the follow in didAddSubview before its surface joins, so it never takes a hold), so c4d6c936c92 moves it to the parked-tab path the review named: red at 4e8f53fc46b (= 7abac06b51d + that test), step f257aa893b08d66204c893d1 ("the landing asked the PTY for [80x44, 88x44] after the presses' grids"; the reverse-press test fails there too); green at c4d6c936c92, step 4e80341dde6de693eb6a5a67 (PaneContentPolicyTests passes).
  • G2 (P3, not proven): after F1 holds a stale grid at the landing, Ghostty's reflow preview can show that stale frame rewrapped for one refresh. Not changed here; noted for the flip PR's films.
  • T5 (the review at c4d6c936c92, SHIP, comment 6640562380; P2, to fix before ready-to-land): the default path (switch off) lost its "one surface size per press" checks when the m6 tests turned the switch on. c0c7f9b0100 adds the base's five checks back as their own tests, with the switch pinned off after each await: PaneContentPolicyTests.aKeyPressSizesEachSurfaceOnceWithTheSwitchOff (4 directions), aHeldKeySizesEachSurfaceOnlyInAPressTurnWithTheSwitchOff, aPressAndItsReverseRestsAtTheStartSizeWithTheSwitchOff, KeyboardResizeContentHoldTests.theProducerResizesOnceWithTheSwitchOff and KeyboardResizeReflowAfterLandingTests.theProducerLaysOutOnceWithTheSwitchOff (left, right). They are the base's tests at bb23623917f with new names. They guard behavior that this PR does not change, so they pass on the base and at the head: restored coverage, not a fix, so there is no red step. 162ea37166b corrects the comments the review named (P3): the switch's help text, the PaneContentPolicyTests header, TabStripFollowsPaneTickTests.Content, TerminalHostView.releaseSurfaceHold and the TerminalLaunchFrameKeeperTests waits. 6522020512a (the lead: run the tests with the switch off and with it on) gives the switch as an argument, [false, true], to each other test this PR changed that has a meaning in both states: in PaneContentPolicyTests the F3 guard, F2, and G1 (a) and (b); the TabStrip strip-and-terminal test; both KeyResizeFirstTickPinTests tests; the key-press and held-key ground tests of PaneGroundCoversPaneTests; and the two resize saves of TerminalLaunchFrameKeeperTests. With the switch off, each checks the base's rule: at most one new surface size per press, none at the landing, and one grid per press. The m6 contract tests stay switch-on; their switch-off counterparts are the five restored tests. TerminalKeyResizeFrameTests and TerminalSurfaceCommitSizeTests test the frame owner itself, which runs only with the switch on.
  • H1-H5 (the same review; P3, switch on only, none worse than the switch-off path): recorded for the flip PR, which fixes or decides each one with a red test first. H1: the low-prompt bottom hold keeps the final width and the hold's first edges (a press on the other axis, or two grow presses and then a shrink press, in one spring). H2: after the G1 release, a request in flight from the older hold still lands for one or two refreshes. H3: see Frames. H4: a bottom hold whose frame waits shows a band at the top for one or two refreshes. H5 (contrived): a grid that the F1 rule holds can wait with no follow-up.
  • Other items: TerminalFramePresenter.debugState(of:) is debug-only; place, requestWantedFrame and landRequestedFrame have perf-hot markers; the contract comments say what each switch state does.

Files on the per-frame and per-key paths since the films and typing were measured (8c981e32979): TerminalSurfaceView.swift (F1: the grid report decision; no present change), TerminalSurfaceView+PaneMotion.swift (F2: the press start; G1, 5aab4968d19: with the switch on, restAt releases an older surface hold at a press) and TerminalHostView+PaneMotion.swift (F2: the surface frame of a low prompt in a pane that grows). pixel-perfect refilmed the key journeys at ded0cb09a56 (the code of 447f784c3cf with the default on; see Observed). G1 changes no frame of those journeys: it acts only when a press comes while an older hold is on the surface (a low prompt's bottom hold, or a terminal that joined mid-hold), and the films have no such press; the two G1 tests check its frames. TerminalFramePresenter.swift and TerminalSurfaceView+FrameOwner.swift change only by a debug guard and comments; TerminalHostView.swift and TerminalDragTunables.swift change only by comments and the switch's help text (162ea37166b).

Why the default stays off. In a held key resize with 7 or more moving terminals, ON misses more refreshes: keys-stress held, cmux24 job c33bd85dbb064376b348d0c2 (ref 6a266873a17, the app code of 665b1874e6e), 6 rounds per arm: 44 missed with OFF and 55 with ON, ON higher in 5 of 6 rounds; the mean main time of a motion frame grows by 0.27 ms with 7 moving terminals. Single presses, the 4-terminal key resize and drags are equal or better, and ON adds no layout per frame (16.9 view layouts per frame in both arms).

  • Cause: each moving terminal gets a new IOSurface each frame (Ghostty allocates a render target per size), and each frame drawn at a new size wakes the main thread through a main-queue block that the main thread then drops.
  • Fix: a Ghostty fork change: one coalesced frame-ready wake for the embedder, and render-target slack, so that one press reuses one IOSurface. Owner: frame-owner-s4, with PL-ghostty for the fork. Dates: fork PR by 2026-11-05 18:00Z, then its release and the pin move.
  • ON date: the flip PR by 2026-11-06 12:00Z. Its gate: after the fork fix, the held keys-stress A/B on cmux24 (--rounds=6 --key-repeat=6) is no worse with ON (pixel-perfect's rule: a finding if ON misses more in at least 5 of 6 rounds). The switch deletion follows the flip.

Frames (what the person sees in each frame with the switch on, before and after; REVIEW.md section 12). With the default off, nothing changes for a person in this PR:

  • Frames: terminal, key resize (Ctrl-Shift-H/J/K/L). Before: the tab strip moves with the pane, but the terminal text stands at one size through the spring and jumps to its final layout at the end. After: in every frame the terminal shows its text at its pane's size. One size is in flight at a time and the next one is asked for when it lands, so in most spring frames the terminal shows the previous frame's size, anchored top-left: clipped at the pane's right or bottom edge when the pane shrinks, and beside a strip of the terminal's own ground on the pane's right or bottom edge when it grows (the moving edge for a pane that grows right or down; the edge that does not move for a pane that grows left or up); never stretched. The program gets its new size once per press, at the start. Two exceptions. (1) A pane that grows taller (down or up) with its prompt in the lower half keeps its terminal at the final size on the pane's bottom edge (as a split and a close do), so the prompt stays in view and the rows above it come into view as the pane grows. (2) H3 (decided in the flip PR): a pane that gets shorter with its prompt on the last row shows the old grid cut at the bottom until the terminal's owner announces the press's grid (one or two refreshes with the local daemon, more for a remote terminal), because Ghostty's reflow preview draws only at the final size. With the switch off the surface has the final size, so the prompt stays in view.
  • Frames: terminal, split, close and zoom springs. Before: the terminal's bounds changed at once, and Ghostty's frame of that size came 1-4 refreshes later, so the old frame stretched or left a gap. After: each new size shows together with its frame drawn at that size; until then, the previous frame stays at its own size.
  • Frames: terminal, divider drag: no change (terminalDrag.holdSurface keeps the text through the drag and reflows once at the release).
  • Frames: tab strip, page, agent chat: no change.
  • No content stops following its pane.

The default stays off (447f784c3cf). This branch had the default flip (b3ab63bded6, then 1e0df52dcc6); the lead reversed it: no new red, and a stage turns ON only when no layer is worse than OFF. TerminalDragTunables.framesOwnedByCommit defaults to false. The key-resize contract tests turn the switch on for their frames (PaneGroundCoversPaneTests.ownFrames(); the store is process-wide, so an async test turns it on again after each await), and the default path's guards pin it off (ownFrames(false)). The What's New highlight moves to the flip PR.

Tests changed to the m6 contract (cmux-ad m6 (c), for terminals). These test windows draw nothing, so each size shows at once:

  • PaneContentPolicyTests: aKeyPressShowsEachSurfaceAtItsPanesSizeAndSendsOneGrid, aHeldKeyShowsEachSurfaceAtItsPanesSizeAndSendsAtMostOneGridPerPress, aPressAndItsReverseInOneHoldSendEachGridInItsTurn.
  • KeyboardResizeContentHoldTests.theTerminalFollowsItsPaneInEveryFrameAndSendsOneGridPerTerminal.
  • KeyboardResizeReflowAfterLandingTests.theTerminalFollowsItsPaneInEveryFrameAndItsGridGoesAtThePress.
  • In every frame, each surface has the size its pane shows it at. The grid checks (one grid per press) do not change.
  • The default path keeps the base's checks (T5): the base's versions of the first five tests above, renamed ...WithTheSwitchOff, with the switch pinned off: each surface takes one size per press, none in a spring frame and none at the landing. The other key-resize tests this PR changed run in both switch states (6522020512a).
  • TabStripFollowsPaneTickTests (b), the lead's m6 (c) item: aKeyResizeKeepsEachStripAndTerminalOnItsPaneInEveryFrame puts a real terminal in each pane: in every frame each strip is on its pane and each terminal at its pane's size, one grid per press. Red on feat bb23623917f (step 705edba00632aa949c1144e5: the surfaces held at the press's final size), green with this PR (step 03f179c8c4a3ed7ec729e21a).
  • TerminalLaunchFrameKeeperTests: its two resize cases wait (2 s at most) until the surface shows its new size. With the frame owner, the surface takes that size in the commit that shows its frame, a refresh or two after the layout, and the keeper saves it then.

Debug only (DEBUG and CMUX_BENCH_HOOKS). The debug.motion trace records, per frame and per terminal: the layer rect, the shown frame size, Ghostty's present counters, the presenter state and the host frame state (TerminalSurfaceView+MotionSample.swift, TerminalFramePresenter.debugState(of:)). The film judge uses it to tell which present path drew each frame.

Merge of feat (bb23623917f). #35161 deleted motion.frameOwner.contentPolicy and PaneMotionHold.containersFollow, so containers always follow. The merge commit removes the key-resize test's contentPolicyOverride pin, and c642350625a removes the containersFollow: arguments from the kept-frame test.

Not caused by the switch, and not held here (pixel-perfect's alternating rounds, STATUS 11574). A whole-window +1 refresh in drags comes and goes between films, in both modes. With build 5eb5406 it followed ON three times in round 1, then showed in two OFF films and in no ON film in round 2. Its owner is not found yet. A drag-cadence test written to find it (f863559be0c) never reproduced it and is removed here.

Testing

Display run at this head: cmux-ci display step d33333ef60fbc8bb4250b0f0 (--display --label gui-session-v1, ref 6522020512a03fd915d9a1f9e23c357e7633da20, done on cmux9s-Mac-mini, exit 0): TerminalKeyResizeFrameTests 2 tests, TerminalFrameOwnerMainThreadTests 1 test, TerminalSurfaceCommitSizeTests 6 tests; all passed. The 42 terminal suites at this head: step 167bfcdf2e06625c1688bc1d, all passed.

TerminalKeyResizeFrameTests (display workers) uses the app's PaneContentView (tab strip over content area), with a terminal in each of p1 | p2, in a window on screen. It presses Ctrl-Shift-L once on p2, so p2's left edge moves (the film's case). The test steps the spring itself. After each frame's commit, it checks each terminal:

  • its top-left is within 1 device px of its area's top-left;
  • its size is one that its pane had in this press;
  • the frame is never stretched;
  • after the landing, it shows its final size;
  • its PTY gets at most one grid per press.

It also prints each frame's main-thread time (the step plus the layout).

Red, then green (fleet steps):

  • TerminalKeyResizeFrameTests: red at b367fadbdae, step 4cea036c2b42cb8eab55aab0: misplaced 15, unsized 28 (p2 kept the final size on the still trailing edge; p1 showed its final size before its pane had it). Green at c642350625a, step 6082bec2c6cef89d9a1f48d2: misplaced 0, unsized 0, stretched 0, PTY grids per terminal [1, 1]; main ms per frame p50 0.30, p95 0.44, max 0.79 (0.27 / 0.41 / 0.55 at the red). At 1e0df52dcc6 (the default on): step 989a83316987d65da365e75e green: misplaced 0, unsized 0, stretched 0, PTY grids per terminal [1, 1]; main ms per frame p50 0.34, p95 0.63, max 0.96.
  • aKeptFrameStaysOnTheEdgesItsHoldNames (an S3 hold with a size change): red at 0ffa721f8d4, step caf779c086289d39ce7fda9d, with "the narrower size did not wait for its frame". Green at c642350625a in step dac29204614b3d8bbb99c5a7 (TerminalSurfaceCommitSizeTests passed).
  • The m6 (c) contract tests: red at 2da280e5c61 (switch off), step 95093a235a1f69730aa52111: PaneContentPolicyTests, KeyboardResizeContentHoldTests and KeyboardResizeReflowAfterLandingTests fail, each with a surface held at the press's final size while its area moves (for example "frame 1: p1's surface (534.0, 772.0) in a (591.0, 772.0) area"); no grid check fails; TerminalLaunchFrameKeeperTests passes. Green at 1e0df52dcc6 in the step below.

All test suites that drive a terminal surface (every CmuxNextAppTests suite that uses TerminalSession or TerminalSurfaceView): at 1e0df52dcc6, step da4c624b9f266aaf509def51: 41 suites pass, and TerminalFrameOwnerMainThreadTests needs a display worker (it runs in the display step). At 447f784c3cf (default off): step 28e2a76129822912cbeb9023: all 42 suites pass; display suites, step 538dccf3f6002506fb3b7549: all 3 pass. At 5aab4968d19 (the G1 fix): step 62747f12ead93b25cd1c67f3: all 42 suites pass; display step 840f73703081fc6a206fd781: all 3 pass. c4d6c936c92 changes one test only. At 162ea37166b (T5's restored tests and the comments): step c8543eac4261795bf6b12ce6: all 42 suites pass. At the final head 6522020512a (the tests in both switch states): step 167bfcdf2e06625c1688bc1d: all 42 suites pass, and each both-state test ran its two cases (for example aKeyResizeKeepsEachStripAndTerminalOnItsPaneInEveryFrame(direction:switchOn:) with 8 test cases, aReversePressAfterALowPromptGrowsDownFollowsItsPane(switchOn:) with 2); the five ...WithTheSwitchOff tests pass. Display step d33333ef60fbc8bb4250b0f0: all 3 suites pass; the key resize shows misplaced 0, unsized 0, stretched 0, PTY grids per terminal [1, 1]; main ms per frame p50 0.32, p95 1.29, max 13.21 (one frame; the same app code gave max 0.89 at 5aab4968d19 on another worker; this test does not judge main time, the A/B does). Receipt: SHIP at 6522020512a (comment 6642054231, the same reviewer as the SHIP at c4d6c936c92). At c642350625a, before the test changes, 38 passed and the 7 old-contract tests above failed (step e9e6a30c3d9effb058d77e64).

A/B in one launch (#35407's bench; ref fix/frame-owner-s4-abbench-nomerge = 6a266873a17, whose app sources are those of 665b1874e6e: before F1, F2 and G1. The decision to keep the default off does not depend on them; the flip PR runs the A/B at its own head). Command: split-spring.sh --suite=terminal-resize --config=bench --vdisplay=120 --layouts=keys,light,stress,keys-stress --ab=motion.frameOwner.terminals. The keys layout is 4 terminals in a 2x2 grid with Ctrl-Shift-L, then Ctrl-Shift-H. Each round runs both arms, and the arm order alternates per round.

  • First run (cmux24, job b5834b1d8ebc36c55386d7cd, 3 rounds per arm; the light and stress drag layouts did not run because of a bench counter defect, fixed in bench(terminal-resize): probe the drag, accept held terminals, A/B key resizes in one launch #35407):
    • keys, OFF: 534 frames, 5 missed; main ms p50 0.11, p95 3.53, p99 7.32, max 11.63; frames that laid out a view p50 2.49, p99 11.05.
    • keys, ON: 534 frames, 8 missed; main ms p50 0.12, p95 3.93, p99 8.90, max 12.32; frames that laid out a view p50 2.99, p99 12.03.
    • keys-stress (7 terminals resize per press), OFF: 4 missed, main p99 8.52. ON: 6 missed, main p99 8.18.
    • One grid per press in both arms and both layouts (max_grids_per_press 1).
  • 6 rounds per arm (cmux24, job 597e886a2188c237394e92ac, ref 6a266873a17, all probes passed: the light drag moved -40 pt and 2 terminals took a new grid; STRESS 7):
    • keys (4 terminals): missed refreshes OFF 15 / ON 15. Frames that laid out a view: OFF n 213, p50 3.33, p95 7.54, p99 11.53, max 12.05 ms; ON n 249, p50 3.04, p95 8.40, p99 13.17, max 14.11 ms. Per round, p99 OFF 7.54, 12.05, 11.53, 10.04, 10.88, 11.58 (median 11.2) / ON 3.75, 14.11, 12.64, 13.97, 12.37, 12.65 (median 12.65).
    • keys-stress (14 terminals, 7 take a new grid per press): missed 18 / 18; p50 3.93 / 4.46, p95 9.88 / 10.27, p99 11.28 / 11.85 ms; per-round p99 medians 11.18 / 11.76.
    • light drag (2 terminals): missed 3 / 1; p99 7.01 / 7.07 ms. STRESS drag (14 terminals): missed 22 / 8; p99 9.19 / 8.55 ms.
    • One grid per press in every key layout and both arms (max_grids_per_press 1).
  • Reading: for single presses, ON drops no more refreshes than OFF in any layout (fewer in drags). Held keys with many terminals: see below. Its one cost is about +1.4 ms at the p99 of a 4-terminal key resize's motion frames (p50 is lower): each terminal now gets a new size and a present in each frame instead of once per press, which the m6 contract asks for. Both arms are above the 8.33 ms frame budget at the motion p99 (11-13 ms) in key resizes. That is the base's main-thread work in a spring frame, not this change; it stays an open item for the motion owner.
  • A held-key 6-round run (--key-repeat=8, step 0ac1a7fbbc0903d1d6413981, cmux-austin-mini-1, a saturated host: the STRESS drag misses about 400 refreshes per arm there): keys 69 / 70, keys-stress 380 / 455 (ON higher in 5 of 6 rounds), light drag 76 / 41, STRESS drag 419 / 406 (OFF / ON); one grid per press in both arms. On cmux24 (job c33bd85dbb064376b348d0c2, --key-repeat=6) keys-stress held is 44 / 55, ON higher in 5 of 6: the reason the default stays off (below).

Films (pixel-perfect, round ab-s4on-sw: build of 8c981e32979, the same app code; cmux24, 120 Hz at 2x; one launch per journey with the switch set and read back in every film; judge 3e.1). m6 failing film frames, OFF / ON:

  • px-keys-single-3k: terminal x1 1 / 0, Reduce Motion 0 / 0. Page 104 / 103 and 68 / 69: the same in both arms, all the page's own shell gap (the kit turns the page backdrop off; pages are S5). Press to first divider move: x1 2 1 1 1 / 2 2 1 1 refreshes; Reduce Motion 2 2 2 2 / 2 2 2 2.
  • px-keys-held-2: terminal x1 2 (E4) / 0, Reduce Motion 0 / 0. Page 130 / 132 and 81 / 77.
  • px-close-column-c3: terminal 63 / 64 and 60 / 61, all E3 at rest after the landing in both arms: a judge artifact (two terminals' grounds of the same color read as one run; R-HARNESS item 3e.3). Not S4.
  • So in ON, no terminal frame of a key resize is off its pane. OFF has 1-2 such frames per film.

Whole-window "late" frames (E7) in held keys. The first held-2 film had 155 late frames with ON and 6 with OFF. A repeat (ab-s4rep: 3 OFF/ON pairs of held keys in one launch) gave OFF 5, 147, 4 / ON 109, 160, 162. E7 follows each film's fitted clock: in the late films, the tab strips and the terminal content both sit one trace sample behind the fixed clock, for the whole film, and this happens in OFF films too (off2). The person-side measure is the refreshes from each key-down (the app's input journal) to the first divider move in the film, with no trace involved. First press of each film (the only one that starts from rest): OFF 2, 2, 3 / ON 1, 2, 2; single-3k OFF 2 1 1 1 / ON 2 2 1 1; held-2 first press OFF 1 / ON 1. No press is slower in ON as a whole, so the E7 difference is a trace-clock effect, not latency a person sees. A second repeat with per-film debug.frames (ab-s4rep2: 3 more pairs) confirms it: the fitted clock was +1 in all 6 films, and E7 was OFF 73, 160, 158 / ON 158, 158, 162. Over all 7 held-key films per arm, OFF was late as a whole in 4 and ON in 7: the late state follows the run's conditions in both arms. First press, refreshes to the first divider move, over 6 films per arm: OFF mean 2.17, ON mean 1.67.

Typing (pixel-perfect, typing-load-l6 in-build ABBA on cmux22, build of the same app code, the switch set after each relaunch; key to committed frame, ms): ON p50 3.33 and 3.07, p95 8.87 and 8.75, p99 11.40 and 10.65, max 29.6 and 16.3; OFF p50 3.04 and 3.04, p95 8.98 and 8.78, p99 11.42 and 11.97, max 20.6 and 27.8. Mean p99 ON 11.03 vs OFF 11.70 ms; dispatch-to-commit p99 ON 3.74-3.94 vs OFF 5.09-5.64 ms. Typing is not worse with ON: PASS.

Main thread in moving frames (ab-s4rep2, held keys, frames with a view or window layout; debug.frames raw per film), OFF / ON per film:

  • p50: OFF 3.01, 2.63, 2.65 / ON 2.73, 2.82, 2.78 ms.
  • p95: OFF 7.51, 6.08, 6.02 / ON 6.82, 6.21, 4.44 ms.
  • max inside the key motion: OFF 13.50, 10.09, 13.87 / ON 9.12, 14.25, 6.22 ms (the 14.25 ms ON frame and OFF's 12.90 ms are each the first frame of the first press). The film's longest frame (OFF 17.17, ON 25.98 and 24.12 ms) is one frame about 1.8 s after the motion, in both arms: the recorder stopping at the film's end, not a key press.
  • frames over 8.33 ms inside the key motion: OFF 6, 4, 2 / ON 1, 5, 0. Missed refreshes (whole film): OFF 6, 2, 3 / ON 4, 5, 0.
  • Ghostty display passes on the main thread (debug.terminal_display): OFF 21 per film (p95 2.6-2.7 ms, about 24 ms in all); ON 0 (the presenter shows each frame in the commit instead).

followedSurfaceFrame is // perf-hot: 4ms motion-frame (owner PL-motion-core). With the switch on it returns at once, with no hold arithmetic. The keys A/B above is its before (off) and after (on). The benches that perf-hot-benches.json names for that path, base bb23623917f against 665b1874e6e (the switch on by default there), on aws-m4pro-9 (the windows were not key, -inactive):

  • split-spring.sh --suite=pane-perf (split, close, New Column, zoom and drag with terminals), 3 rounds, base first (steps 2ebdfd09d4e2f2955eab6e18, 7953a9ae8bf86998b219d294): missed refreshes 1x2 1 / 7, 4x2 6 / 5, STRESS 24 / 24. Main-thread layout per frame p95 within 0.3 ms in every row.
  • The same, 6 rounds, head first (steps 6524b0b7715245a0f244440a, 9bac0780bb89bdf00476ea0e): 1x2 4 / 4, 4x2 6 / 8. So the 1x2 difference of the first run was order or noise.
  • --suite=anim-perf (tabs, splits, columns, workspace switch, sidebars, Home, chats, palette), 3 rounds (steps 894bfe132a38aa27953d00e0, b85d56b55bb8a5c8595a6024): dropped frames 53 / 51 over 27 animations; no row moves by more than 2. Both steps exit 1 on the same 4 rows (tab moves that the bench's layout refuses: "the tab is already at the edge"), in both arms.

Observed / Not run

  • Observed: the fleet steps above, the first A/B run, the films and their repeat, and prepush-gate.py passes.
  • Films of ded0cb09a56 (the same code as 447f784c3cf with the default on; pixel-perfect, cmux24, judge 3e.1). OFF arm (F1 is its only change): not worse. held-2: 3 terminal frames (E4) vs 2-3 in the earlier OFF films; close-column-c3: 59 and 60 vs 63 and 60; single-3k: 3 frames at the J press vs 1 earlier, inside the old spread (5 frames in the OFF films of c4d64b5 and 30df24f), with the tab strip and the page late by the same amount, so the cause is the commit, not F1; whole-window late 42 vs 51. ON arm: terminal 0 in single-3k, 1 (E4) in held-2, 63 and 60 in close-column-c3.
  • F2 (new journey px-keys-lowprompt-3kp): before F2 (8c981e32979, switch on) the prompt row was hidden at the start of each grow (4 frames at x1, 17 at x0.25). With F2 (ded0cb09a56, switch on) and on the switch-off path, the prompt row stays in view in every frame of the grow-down spring (0 of 23 frames hidden at x1, 0 of 70 at x0.25), and no shell gap opens. pixel-perfect keeps F2 in S4 (STATUS 12689); the lead can reject it until 2026-11-05 12:00Z. When the final grid's frame arrives, the prompt row moves up once (7 px OFF, 11 px ON) because the bottom padding grows, then it rides the bottom edge.
  • Reviews: BLOCK at 665b1874e6e (F1-F3), BLOCK at 447f784c3cf (G1), SHIP at c4d6c936c92 (comment 6640562380) with T5 to fix before ready-to-land. c0c7f9b0100, 162ea37166b and 6522020512a fix T5 and the comments; the same reviewer's receipt at the final head is the one for ready-to-land.
  • Not run at the final head: films and the A/B. Since c4d6c936c92 the head adds and changes tests, and changes comments and the switch's help text only, so the app's behavior is that of 5aab4968d19.
  • Stage ON rule (pixel-perfect): on films of the head, no layer is worse with ON than OFF, and typing is not worse. Films: met (0 terminal frames off their pane with ON). Typing ABBA: PASS. A/B: single presses, 4 terminals and drags equal or better; held key resize with 7+ moving terminals worse (above), so the default stays off. The refactor's final acceptance (main-thread p99 < 4 ms per 120 Hz frame, PLAN.md) is not met in either arm in key resizes (11.53 ms OFF / 13.17 ms ON): open for the later stages (S5, S7, the main-thread work).
  • Owner note (PL-ghostty, session cmux-b6): with the switch on, this changes the terminal surface's frame in key resizes; the default stays off. That is seam (b) in plans/cmux-next/ghostty-integration-DESIGN.md section 3: the CA commit that carries a pane's new frame carries the terminal's frame at the new size, or the previous frame unchanged. The owner is told in STATUS.

No capture: with the default off nothing a person sees changes. The switch-on evidence is pixel-perfect's films above (OFF and ON in one launch) with the per-frame judge.

Changelog

none

…n.frameOwner.terminals on than off (red)

Films ab-s4d-sw-22 (build 645ad29d, px-row-drag-3r, judge v7): in a divider
drag, the terminal's content edge lagged its pane edge mostly 2 refreshes
with the switch on (p2's moving edge -4 px, 156 of 667 frames), against 1
refresh with it off (-2 px, 117 of 676).

TerminalFrameOwnerDragCadenceTests drags a real Ghostty surface one size per
refresh of its window's display link (a FrameClient on the window's
FrameScheduler), first with the switch off, then on. At each refresh it reads
how many refreshes old the size of the shown frame is, and with the switch on
counts Ghostty's state for the newest size (ready: drawn, not presented yet;
pending: not drawn yet). On may not be later than off (median and p95).
Display workers only.

Red command: cmux-ci run --display --label gui-session-v1 --class light
--script scripts/ci/package-test-lane.sh --arg=suite
--arg=Packages/macOS/CmuxNext --arg=TerminalFrameOwnerDragCadenceTests
…trailing, bottom) while its new size waits (red)

S4 ON PR defect (b), found by the Codex review at 15c8848. In a key
resize the host follows its pane (FRAME OWNER S3, #34170) and gives the
surface its size on the hold's still edges (PaneMotionHold.producerFrame).
With motion.frameOwner.terminals on, while a new size waits for its frame,
place() kept the old frame on the wanted frame's leading edge, and on its
top edge unless the surface hold said bottom. So with a trailing hold the
kept frame's trailing edge moved off the still edge (by the size change), and
with a bottom hold its bottom edge did, until the new size showed.

aKeptFrameStaysOnTheEdgesItsHoldNames: a trailing hold with a narrower final
size, then a bottom hold with a shorter one. While each size waits, the kept
frame keeps the rest size on the still edges; the landed frame is the
producer frame.

Red command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh
--arg=suite --arg=Packages/macOS/CmuxNext --arg=TerminalSurfaceCommitSizeTests
…s host follows (trailing, bottom)

Fixes aKeptFrameStaysOnTheEdgesItsHoldNames (previous commit). With
motion.frameOwner.terminals on, while a new size waits for its frame, the
kept frame now sits where FRAME OWNER S3's PaneMotionHold.producerFrame puts
the surface: on the trailing edge when the followed hold keeps its trailing
edge still, and on the bottom edge when the hold or the surface hold says
bottom. Before, it rode the wanted frame's leading edge, and its top edge
unless the surface hold said bottom.

- TerminalSurfaceView.place(_:anchor:trailing:) and anchoredFrame(...,
  trailing:) carry the horizontal edge (TerminalFrameOwnership.trailing).
- TerminalHostView: surfaceAnchor is bottom when the surface hold or the
  followed hold says bottom; surfaceTrailing is the followed hold's trailing.

With the switch off nothing changes: place() sets the frame it gets, as
before; only the kept frame (switch on) uses the edges.
…the bare-window version did not reproduce)

f355d15 resized a bare terminal from a display-link tick: step
569b148813d9d863e5bb673b passed with the switch off and on (the shown frame
0 refreshes old in all 52 refreshes), so it did not reproduce the films' row
drag lag (ab-s4d-sw-22: content edge 2 refreshes behind with the switch on, 1
off). In a drag the surface keeps its start size; only its origin moves.

aRowDragMovesTheTerminalWithItsPaneEdgeNoLaterWithTheSwitchOnThanOff now
uses the real path: p1 above p2 in a LayoutRootView in a window on screen, a
terminal in each, and the app's divider drag (ScreenContentView.handleDrag),
one 2 pt move per refresh of the window's display link, switch off then on.
After each Core Animation commit it reads, in the committed layer tree, p2's
pane host top edge and its surface layer's top edge. On may not lag in more
refreshes, or by more, than off. Display workers only.
…t path per frame (for the ON row-drag film)

FRAME OWNER S4 defect (a): with motion.frameOwner.terminals on, the films'
row drag shows the terminal's content edge 2 refreshes behind its pane edge
(1 with it off). The committed layer geometry has no lag in either mode (the
real-layout test f863559), and the film's trace shows the panes moving
the same way, so the extra refresh is in the present path.

Debug and bench builds only (DEBUG || CMUX_BENCH_HOOKS): a debug.motion
trace sampler `terminal`, per display frame and shown surface: its layer
rect (window points, top-left), the shown IOSurface size, Ghostty's render
counters (frames, off-main presents, fresh presents, display passes, main
draws, late presents), whether its window's commit owns its frame, the
presenter's state for it (TerminalFramePresenter.debugState: request, watch,
commit or -) and ghostty_surface_host_frame without presenting. It reads
only while the trace records. Registered once by the first surface view.
…ne's size in every frame, and its program resizes once per press (red)

Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but
browsers/terminals do not anymore". The key resize's hold (RESIZE KEYS K3,
TerminalSurfaceView.restAt) holds each surface at the press's final size for
the whole spring; with FRAME OWNER S1/S3 the tab strip and the host follow
the pane every frame, so the terminal's pixels freeze, then jump. With
motion.frameOwner.terminals on (S4), the size still changes once per press.

Contract (lead, cmux-ad m6): pixels per frame, program size per press.
TerminalKeyResizeFrameTests, on the real path (LayoutRootView p1 | p2, a
spring with Reduce Motion off and S3's content policy on, PaneResize.press),
switch on: in every committed frame of the spring each terminal's surface
layer is within 1 device px of its pane area's size in that frame or the
frame before; each PTY gets at most one grid for the press; ready-or-keep
holds. It prints the frame intervals (p50, p95, max). Display workers only.

Red command: cmux-ci run --display --label gui-session-v1 --class light
--script scripts/ci/package-test-lane.sh --arg=suite
--arg=Packages/macOS/CmuxNext --arg=TerminalKeyResizeFrameTests
… wall-clock time (idle run-loop turns ended it before the spring moved)

Step 2780d048a25b653555a50640 at 418d4a8 failed its precondition, not the contract: "moving 1 >= 4". It stopped after 12 commits with no change, and a run-loop turn is not a refresh: 12 idle turns passed before the spring's first frame. The test now keeps every committed state that differs from the last one for 1.5 s of wall-clock time; settle waits for 300 ms with no change. Still red-first: no fix in this commit.
… itself (the display-link spring gave 2 states)

Step dd347b7008f47da4762b1d62 at 73c411a failed the precondition again: "moving 2 >= 4". In 1.5 s the committed state changed twice, so the display-link spring did not animate on that host. The test now drives the layout as TabStripFollowsPaneTickTests does: driver.isManual, then per frame advanceFrame(1/120), its layout pass and its Core Animation commit (both presses). It also times each frame's main work (step + layout). Still red-first: no fix in this commit.
…inal's top-left on its pane's (the film gate), and sizes the pane has had

pixel-perfect's film on dogfood 437f5a22 (px-keys-single-3k-s1r): the terminal right of the divider has its left ring on its pane's left edge in 14 of 50 motion frames, off by -8 to +21 px in 20, cut off in 16. A size check alone misses a wrongly placed frame. Per committed frame and terminal the test now requires: the surface's top-left on its area's top-left (1 device px, in the window's root layer); a size the pane had in this press, this frame or earlier; the final size after the landing; at most one PTY grid per press. Still red-first: no fix in this commit.
…p-left anchor (scenario kept from S4 defect (b))

The case that once showed a kept frame on the wrong edge (red step d509169a15477622073341e5) stays, with the lead's m6 rule as its expectation: in a key resize whose S3 hold keeps the pane's trailing or bottom edge still, when the pane narrows or gets shorter the terminal asks its new size, keeps its last frame anchored on its area's top-left (never another edge), and lands at the area. The size now comes from the pane's area (ResizingContainer.terminalHeight added), as in the app.

Red on the current code: the followed surface keeps its start size at the still edge (FRAME OWNER S3 + K3), so no new size waits: 'the narrower size did not wait for its frame'. The fix is the next commit.
…aneContentView, so the key hold follows the pane as in the app (red)

Step f3f4a7f9378f4d1455c4e12b (6cf19a0, cmux11) ran the stepped spring
(27 frames, main per frame p50 0.08 ms, max 0.22 ms; one PTY grid per press)
and failed 48 frames: "layer 500x600, pane area 450x600". But its panes were
plain NSViews, so the key hold held the whole content (RESIZE KEYS K3 without
FRAME OWNER S1/S3): that is a red for the K3 hold, not the app's path.

The panes are now the app's PaneContentView (its tab strip over its content
area): the key hold follows the pane in every frame (S1/S3), as in the app,
and only the producer holds. Per committed frame and terminal: top-left
within 1 device px of its area's, a size its pane has had in this press,
never stretched (layer px == the shown IOSurface's px); after the landing the
final size; at most one PTY grid per press. Display workers only. Still
red-first: no fix in this commit.
…resize shows each terminal at its pane's size in every frame; the PTY gets one grid per press

Lawrence on dogfood 363e39f2: "tabbar moves smoothly on ctrl shift hjkl but
browsers/terminals do not anymore". In a key resize the host follows its
pane (FRAME OWNER S3), but the surface kept one size per press: restAt held
it at the final size (or S3 at the start size) on the hold's still edges, so
the content froze, then jumped (film on 437f5a22: the terminal right of the
divider had its left ring on its pane edge in 14 of 50 motion frames).

Contract (lead, cmux-ad m6): pixels per frame, program size per press. With
the switch on:
- TerminalHostView.followedSurfaceFrame gives the surface the area it shows
  in, in every frame. The frame owner (S4) shows each size's frame with its
  bounds, or keeps the last frame anchored top-left until it is drawn (one
  size in flight, the newest next): never stretched, never off the pane's
  top-left. No main-thread draw or wait: set_size goes once per landing, and
  the drawing is on Ghostty's renderer thread.
- TerminalSurfaceView.restAt still reports the final grid at the press (the
  PTY's one resize), but no longer holds the surface at the final size.
- The kept frame's edge is the top-left again: S4 defect (b)'s code (the
  kept frame on the S3 hold's trailing or bottom edge, b0a6f60) is
  reverted. Its test keeps its scenario with the top-left expectation
  (previous commit).
Switch off: unchanged (S3's producer hold on the still edges).

Green command: cmux-ci run --display --label gui-session-v1 --class light
--script scripts/ci/package-test-lane.sh --arg=suite
--arg=Packages/macOS/CmuxNext --arg=TerminalKeyResizeFrameTests and the
terminal suites (TerminalSurfaceCommitSizeTests, KeyboardResizeHoldTakeoverTests,
KeyboardResizeContentHoldTests, DividerDragTerminalSteadyTests, ...).
…the drag's +1 refresh)

The drag cadence suite (f863559) drove a real-layout row drag to find the
+1 refresh that films showed in drags. It never reproduced it, and
pixel-perfect's alternating rounds (STATUS 11574) showed the +1 refresh comes
and goes between launches in both modes: it is not caused by
motion.frameOwner.terminals. The suite holds no regression, so it goes before
the ON PR.
… ON step)

Terminals are ready-or-keep by default: in every Core Animation commit a
terminal's bounds and its pixels change together, or both keep their last
size, and in a key resize each terminal shows its pane's size in every frame
while its PTY gets one grid per press (cmux-ad m6). Release and RC read the
default, so this is the step that ships it. The switch stays for the A/B and
goes in the deletion PR.

What's New: release-notes/next/highlights/resize-keys-terminals-follow.md.
…nHold lost containersFollow in #35161)

#35161 deleted motion.frameOwner.contentPolicy and PaneMotionHold's
containersFollow argument (a content area's hold always lets its containers
follow now). The merge e37c317 had no text conflict, but
aKeptFrameStaysOnTheEdgesItsHoldNames still passed the argument, so
CmuxNextAppTests did not build (fleet step 97850bf9a5ad4c353d88329d at
8c981e3: "extra argument 'containersFollow' in call", lines 99 and 118).
The hold means the same without it.
…esize tests check the m6 contract

This takes back b3ab63b (default on and its What's New highlight) on this
branch only, for one commit. With the default on, 7 tests fail at
c642350 (fleet step e9e6a30c3d9effb058d77e64): they check FRAME OWNER
S3's held surface, one size per press, which the lead's m6 contract replaces
(pixels per frame, program size per press). The next commit changes them to
that contract and is red with the switch off. The commit after it turns the
switch on again.
…s per frame and one grid per press (red with the switch off)

The lead's m6 contract replaces FRAME OWNER S3's held surface: in every
frame of a key resize each terminal shows its pane's size (or keeps its last
frame, top-left, until the new size is drawn: TerminalKeyResizeFrameTests),
and its PTY gets at most one grid per press. These tests checked the
opposite, one surface size per press:
- PaneContentPolicyTests: aKeyPressShowsEachSurfaceAtItsPanesSizeAndSendsOneGrid
  (was ...SizesEachSurfaceOnce...), aHeldKeyShowsEachSurfaceAtItsPanesSizeAnd
  SendsAtMostOneGridPerPress (was ...SizesEachSurfaceOnlyInAPressTurn), and
  aPressAndItsReverseInOneHoldSendEachGridInItsTurn (the surface follows in
  every frame and rests at its start size).
- KeyboardResizeContentHoldTests.theTerminalFollowsItsPaneInEveryFrameAndSends
  OneGridPerTerminal (was theProducerResizesOnce...).
- KeyboardResizeReflowAfterLandingTests.theTerminalFollowsItsPaneInEveryFrame
  AndItsGridGoesAtThePress (was theProducerLaysOutOnce...).
These windows draw nothing, so each size shows at once. The grid checks do
not change.

TerminalLaunchFrameKeeperTests: the two resize cases wait (2 s at most) for
the surface to show its new size. With the frame owner it takes that size in
the commit that shows its frame, a refresh or two after the layout, and the
keeper saves it then. These cases pass with the switch off and on.

Red at this commit, with motion.frameOwner.terminals off (the previous
commit). The next commit turns it on.
… ON step)

Terminals are ready-or-keep by default: in every Core Animation commit, a
terminal's bounds and its pixels change together, or both keep their last
size. In a key resize, each terminal shows its pane's size in every frame,
and its PTY gets one grid per press (cmux-ad m6). Release and RC read the
default, so this step ships S4. The switch stays for the A/B, and the
deletion PR removes it.

This is b3ab63b again (taken back for one commit by f231b3c). The
contract tests of 2da280e go green here.

What's New: release-notes/next/highlights/resize-keys-terminals-follow.md.
@cursor

cursor Bot commented Nov 4, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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

Review 6522020: 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=pr35470 -f ref=fix/frame-owner-s4-on -f platform=macos -f backend_mode=local

Passes: CI passes on 6522020512.

CI passes on 6522020512 (run 41906789601 attempt 1).

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

web and size checks: green

Default changes

This pull request changes 1 default at 6522020512a0 (what everyone gets without configuring anything):

  • tunable-source.CmuxNextDesign/Tunables/TerminalDragTunables.swift:framesOwnedByCommit: "cec46348c4368c8f87f121ad539c767297f5b6c6c8b1c8984e0aa7dbd83f3efb" -> "c1643ae1e267dffb8c3e6d0d5607a49453cd2058633b751ca3574b6d2ff589ab"

Signed off on this commit by review (independent subagent).

Merge receipt: merged as 8255dfd7ac42 with these defaults changed, signed off by review (independent subagent).

Regenerate the snapshot with python3 scripts/ci/defaults_guard.py snapshot.

Shipped in:

@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Nov 4, 2026
…3e39f2: terminals stopped following their pane)
@blacksmith-sh

This comment has been minimized.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent): BLOCK at 665b187

Scope: the full diff from the merge base bb23623917f to the head, with the adversarial protocol of skills/cmux-review. Three independent reads (this one and two subagents). They did not see each other's findings. I read the frame owner, the presenter, the host, the surface, the key-resize spring and its release, and the Ghostty fork at the pinned SHA 7d43bb68 (#71; the later #72 and #73 change only comments and counters). Nobody built, ran tests or started the app for this review.

Correctness

F1 (P2, blocks): after the landing, a stale size in flight can send extra PTY grids for one press

Two of the three reads found this on their own.

Where: TerminalSurfaceView.swift:65-68 (endPaneMotion releases the held grid) and TerminalSurfaceView.swift:459, 472, 477-480 (updateSurfaceSize sizes from frameOwnership.inFlight, and reports with holding: false once the spring is over).

Sequence, with the switch on:

  1. Each spring frame asks Ghostty for the pane's size (TerminalSurfaceView+FrameOwner.swift:73-75). That grid is not the final grid, so viewSized(holding: true) sets held (TerminalGridPolicy.swift:53-56).
  2. The landing frame clears isOnLayoutSpring (ScreenContentView+Motion.swift:209). The release is an afterCommit (PaneHostView.swift:121-123). It runs after the presenter's pass and after the commit (AfterFrameCommit.swift:19, order .max; the pass is at TerminalFramePresenter.swift:30).
  3. If the frame of the size in flight is not drawn at that pass, inFlight is still an earlier spring size. Causes: a group wait (TerminalFramePresenter.swift:195), a keep up to keepLimit (:219), or frames held by synchronized output (HELD).
  4. endPaneMotion calls updateSurfaceSize(). It reads the stale size from inFlight and reports its grid, because holding is now false.
  5. When the stale frame lands, the follow-up request (TerminalFramePresenter.swift:241) asks for the final size and reports the final grid again, because reported is now the stale grid.

Result: the view reports three grids for one press (final, stale, final). With the daemon, ResizeCoordinator sends the newest grid when the last echo comes (ResizeCoordinator.swift:61-67, 79-92, 113-117). So the PTY gets one or two extra resizes after the pane stopped, and the program redraws for each. While a stale grid applies, the mirror is locked to it. This breaks "the program grid changes at most once per press". A late announcement does the same thing: applyAnnouncedGrid (TerminalSurfaceView.swift:408-412) calls updateSurfaceSize too. On the base this cannot occur: the key hold put the final size in flight, so no in-between grid was ever held.

When: the stale size must fit another grid than the final size. That is likely when the request is some refreshes old (a slow terminal, frames held by mode 2026, which agent TUIs use), or when the final size is near a cell edge. One slow terminal is enough for all terminals of its group: a group whose member is not ready keeps every member (TerminalFramePresenter.swift:183-198). The first A/B saw at most one grid per press, so it is rare in light terminals.

Why the tests do not see it: the policy tests use windows that are never on screen, so Ghostty answers NONE and no request stays in flight. TerminalKeyResizeFrameTests draws, but its two light terminals are drawn in time at the landing.

Fix: never report a grid from a size in flight that is not the wanted size. For example, in updateSurfaceSize, hold the report while frameOwnership.inFlight != nil && frameOwnership.inFlight != frameOwnership.wanted?.size. The follow-up requestWantedFrame() then reports at the wanted size, and only if that grid is new.

Regression test: in TerminalKeyResizeFrameTests, hold one terminal's frames across the landing (ESC[?2026h before the press, ESC[?2026l after the release; Ghostty answers HELD), and expect no grid after the press's final grid. It must be red at this head and green with the fix.

F2 (P2, needs Lawrence's call): a pane that grows down hides a prompt on its last row for most of the spring

All three reads found this.

Where: TerminalSurfaceView+PaneMotion.swift:62-64 returns before motionAnchor (:74-80), and TerminalHostView+PaneMotion.swift:144 gives the surface the area.

With the switch off, S3 kept the surface at its final size on the moving bottom edge when the prompt was in the lower half, so the prompt stayed in view (the reason is in the comment at TerminalSurfaceView+PaneMotion.swift:70-73). With the switch on, the surface has the area's size, and Ghostty draws the locked grid from the top-left and crops the rows below the view (include/ghostty.h:1612-1615 at 7d43bb68). When the daemon announces the taller final grid, a few frames into a Ctrl-Shift-J or K spring, the last rows (the prompt and the cursor) go out of view. They come back at the landing.

The PR's Frames lines say "in every frame the terminal shows its text at its pane's size" and "No content stops following its pane". They do not name this case. (Columns cropped or padded on the right while the grid differs from the view are part of "program size per press". The bottom prompt is different: S3 kept it in view on purpose, and this PR removes that.)

Fix: keep the bottom anchor for a growing pane whose motionAnchor answers .bottom, or name the case in a Frames: line and keep a HOLD: line until Lawrence accepts it (REVIEW.md section 12).

F3 (P3): a terminal tab shown during a key spring takes the old S3 hold

Where: a terminal that was hidden when the spring began and is shown during it (a tab switch). PaneContentView+Parking.swift:78 unhides it. The surface's syncWindowState calls joinPaneMotion, then restAt (TerminalSurfaceView.swift:286, TerminalSurfaceView+PaneMotion.swift:62). The area hands the hold to the host only in its next layout pass (PaneContentAreaView.swift:110-112, :91-100). So followedHold is nil, and restAt calls holdSurface(final size, motionAnchor).

Result: the surface takes the final size at once (Ghostty still answers HIDDEN, because updateOcclusion runs after joinPaneMotion, TerminalSurfaceView.swift:286-291). That is a size its pane has not had yet. surfaceAnchor (TerminalHostView.swift:397) then keeps the hold's anchor until the landing, so a kept frame can ride the bottom edge, not the top-left. One read judged this harmless; it lasts a refresh or two and needs a tab switch during a spring.

Fix: in restAt, treat a host in a holding area as a follower, for example host.followedHold != nil || (host.superview as? PaneContentAreaView)?.isHolding == true.

Tests

  • T1 (P2): TerminalKeyResizeFrameTests passes for a terminal that stays at its start size through the spring. The allowed sizes include the start size (TerminalKeyResizeFrameTests.swift:66, frames[0...index]). The other checks are the top-left, no stretch, and the size after the settle. A top-left frozen surface passes them all, and that is the reported defect ("terminals do not move"). No test that draws asserts that the surface follows its pane. Fix: bound the lag. For example, each frame's layer has the area of this frame or of the two frames before it, and most frames show this frame's or the previous frame's area.
  • T2 (P3): no test fails without the restAt change. With the switch on, followedSurfaceFrame ignores surfaceHold, so only the anchor of a kept frame in a vertical grow differs, and the on-screen test presses only horizontally. Fix: add a vertical case with the cursor on the last row. It also covers F2.
  • T3 (P3): old-contract tests now pass by accident. PaneContentPolicyTests.theSurfaceStaysAtThePaneEdgeThatDoesNotMove (PaneContentPolicyTests.swift:64-102, and the suite comment at :14-15) and KeyResizeFirstTickPinTests.theHeldSurfaceRidesTheEdgeThatStaysWhenTheFirstTickIsLate (KeyResizeFirstTickPinTests.swift:37-58) check the "edge that stays". With the switch on the surface fills its area, so both edges always match. aKeptFrameStaysOnTheEdgesItsHoldNames (TerminalSurfaceCommitSizeTests.swift:89) checks the top-left; its name says the hold's edges. Change them to the m6 contract, or rename them.
  • T4 (P3): the new waits in TerminalLaunchFrameKeeperTests.swift:216-219 and :239-242 poll the clock (eventually sleeps 25 ms, :55-62). REVIEW.md section 6 asks for a signal, for example a commit boundary.
  • Checked: the changed per-frame tests fail with the switch off (step 95093a23: "38 frames showed a surface off its pane's size") while their grid checks pass. Each needs more than 3 frames, so no loop is empty.

Evidence for the default flip (E1: holds the flip)

  • The PR's own ON pass rule says "no film drops more refreshes with ON than OFF; typing not worse". The first keys A/B does not meet it: keys 8 missed with ON and 5 with OFF, keys-stress 6 and 4, main p99 8.90 and 7.32 ms. The held-key film had the whole window one refresh late in 155 frames with ON and 6 with OFF.
  • Still open: the 6-round A/B (the single-key step 45f5ded1 was cancelled on no worker; the held-key step was still building in its log), the typing pair and the films. The body still has the placeholders @AB@ and @BROAD@. The 42-suite step da4c624b had no result in the log I read.
  • The split-spring bench at the head and at the base (steps 7953a9ae and 2ebdfd09, both on aws-m4pro-9, splits, closes, zooms and drags): 36 and 31 missed refreshes over 21 rows. That is no clear change, and it has no key layout.
  • Release reads default: true (TunableStore.swift:137-141) and has no off switch. The flip also changes live window resize and sidebar slides (the old frame, top-left, until the new size is drawn). The body has no Frames: line for those motions.
  • Recommendation: land the m6 code (with F1 fixed) with the default off, and flip the default in its own PR when the A/B meets the rule. Or keep ready-to-land off until it does.

Performance (from the code; I measured nothing)

For each moving terminal in each frame, with the switch on (with it off, only the origin changes):

  1. Main thread: one ghostty_surface_set_size (Surface.zig:2906-2937). For a daemon surface with a locked grid, the termio resize runs inline with a tryLock and never waits (Termio.zig:815-849). Then setHostDrawableSize (Metal.zig:281-307), two to four ghostty_surface_host_frame calls (TerminalSurfaceView+FrameOwner.swift:50; TerminalFramePresenter.swift:187, 196, 202), each with [CATransaction lock] (IOSurfaceLayer.zig:131-138) and the fresh-frame mutex, the present and the bounds under the lock, and hostFrameCommitted one refresh later.
  2. Renderer thread: each new pixel size allocates a new render target, a new IOSurface (generic.zig:2137-2146, :574-586). Each commit then gives the window server a new IOSurface per moving terminal. This is my first candidate for the whole-window late frames in held keys (INFERRED).
  3. GPU completion thread: a frame at a new size cannot go to the screen off the main thread (setSurfaceOffMain refuses a size change, IOSurfaceLayer.zig:247-275). So present sends a block to the main queue (Metal.zig:464-469, IOSurfaceLayer.zig:206-213), and main drops it (:351-358). Each block wakes a main run-loop turn. The presenter's pass is a beforeWaiting observer (TerminalFramePresenter.swift:167-172), so it can present and commit in that turn, out of step with the display link (INFERRED). The off-main path also begins and commits an empty transaction for each such frame (IOSurfaceLayer.zig:257-258).
  4. Lag: one size is in flight at a time, and the next size is asked for only in the pass (TerminalFramePresenter.swift:241). So in every spring frame the surface shows the previous frame's size, not only for "one or two refreshes". A growing pane shows a strip of the host's ground at its moving edge in each frame, and TUI background colors stop short of it. This is Chrome's resize gutter and fits "or the previous frame's pixels", but the Frames line should say it. Asking for the next frame's size (the spring knows it) would hide the lag.
  5. The test's "main ms per frame" (TerminalKeyResizeFrameTests.swift:218-221) times only the step and the layout. The pass and the commit come after it, so the numbers in the body leave out most of the ON work.
  6. Copy mode: copyMode.syncCursor takes the terminal lock (ghostty_surface_grid_metrics), now in every frame of a key resize (TerminalSurfaceView.swift:368, 461; TerminalSurfaceView+FrameOwner.swift:155-157). It can wait behind the output lane. It needs copy mode during a key resize, so it is rare (P3).

Possible fixes: do not send setSurface to main for a fresh frame while the embedder presents (the pass takes it); present only in the display link's turn; give render targets slack (size buckets) and show the drawn part, so one press uses one IOSurface; ask for the next frame's size.

Checked, no defect found

  • Stretch: the layer has top-left gravity (IOSurfaceLayer.zig:34), so a size difference clips or pads and never scales. Kept and landed frames are top-left (TerminalSurfaceView+FrameOwner.swift:184-195).
  • Sizes the pane never had: in a key resize only sizes of earlier frames land (one in flight, the newest next). Exception: F3.
  • One grid at the press: restAt reports the final grid in the first tick; in-between grids are held while the spring runs; a reverse press sends the start grid once. Exception: F1.
  • Split, close and zoom springs, and the divider drag (terminalDrag.holdSurface): the surface still holds one size, with one grid. A split that takes over a key spring ends the follow first (PaneHostView.endKeyResizeHold).
  • Main thread: nothing in the per-frame path waits, except copy mode (performance item 6). The renderer mailbox never blocks (renderer/mailbox.zig:11-13).
  • Release: it reads the default, true.
  • Debug sampler: compiled out of Release (#if DEBUG || CMUX_BENCH_HOOKS), and it runs only while a trace records (MotionFrameTrace.swift:48-62, 87-94). TerminalFramePresenter.debugState ships in Release with no caller; put it under the same #if.
  • What's New: the digest builder accepts the highlight (category fixed, the docs page exists).
  • Merge of feat: the merge commit changes only a comment in these files; c642350625a fixes the test build.
  • Repository rules: place, requestWantedFrame and landRequestedFrame now run in every frame of a key resize but have no // perf-hot: marker (CLAUDE.md: mark new hot paths). The contract comments still describe S3 ("only the surface's origin moves"): TerminalHostView+PaneMotion.swift:3-8, PaneMotionHold.swift:41-44, PaneContentAreaView.swift:13-15.

Tally: 13 candidates; kept: 3 correctness findings (F1 to F3), 4 test findings (T1 to T4) and 1 evidence hold (E1); refuted: 1 (a blocking termio mailbox push in the per-frame path: the daemon surface resizes inline with tryLock); made lower: 1 (the one-refresh lag, a design note, performance item 4); kept as notes: 4 (copy mode lock, the test's main-ms numbers, perf-hot markers, stale comments).

Review (independent subagent): BLOCK at 665b187

…e landing, a terminal must take new sizes, a low prompt rides the bottom (red)

The independent review of #35470 at 665b187 (BLOCK, comment
6635411077):
- F1: aTerminalWhoseFrameWaitsAtTheLandingGetsOneGridForThePress (display
  workers). p1 holds its frames with mode 2026, with a locked grid as the
  daemon locks it. At the landing a size is still in flight, and the
  landing's release reported that stale size's grid, then the final grid
  again: one press sent 3 grids. The test requires that a size waited and
  that one was in flight at the landing, so it cannot pass vacuously.
- T1: aKeyResizeShowsEachTerminal... also requires each terminal to take a
  new size in more than 3 spring frames. The size check allowed the start
  size, so a terminal that never moved passed.
- F2: aPromptOnTheLastRowRidesTheBottomOfAPaneAKeyGrowsDown. A pane that
  Ctrl-Shift-J grows down keeps a prompt on its last row in view (S3's rule,
  which a split and a close still keep): the surface keeps the final size on
  the moving bottom edge. With the switch on, the surface took the pane's
  size in each frame and showed the final grid's top rows, so the prompt was
  out of view for the whole spring.

Expected red at this commit: F1 (3 grids for p1) and F2 (the surface never
taller than its pane). T1 is green here: it guards the m6 fix.
…o grid; a low prompt rides the bottom of a pane a key grows down

Fixes for the independent review of #35470 at 665b187 (BLOCK, comment
6635411077):
- F1: updateSurfaceSize holds the report while the size in flight is not the
  wanted size (frameOwnership.inFlight != wanted). The landing's release
  sized the surface from a stale spring size whose frame was still waiting,
  and it reported that grid, because the spring was over. Then the newest
  size reported the final grid again: final, stale, final. Now the wanted
  size reports its grid when it goes to Ghostty (requestWantedFrame), and it
  matches the final grid the press sent, so the press sends one grid. A
  forced report waits the same way and goes with the wanted size.
- F2: restAt keeps S3's bottom hold in a key resize when motionAnchor answers
  .bottom (a pane that grows down, with the prompt in its lower half).
  followedSurfaceFrame keeps that hold with the switch on. Before, the
  surface took the pane's size in each frame and showed the final grid's top
  rows, so the prompt was out of view for the whole spring. The PTY still
  gets one grid, and every other key resize follows its pane in every frame.

Red tests: 712df02.
…rface hold (review of #35470 at 447f784, G1)

With the switch on, restAt returned early for a followed host with a .top
anchor, so an older hold stayed until the landing:
- a low prompt's bottom hold (F2) after a reverse press: p1 kept the earlier
  final size on the bottom edge while it shrank;
- the hold a terminal took when it joined mid-hold, before its area handed it
  the follow: after a second press the landing's release sized from it, so
  the PTY got grid1, grid2, grid1, grid2 for two presses.
restAt now releases the hold (TerminalHostView.releaseSurfaceHold) before it
returns .top. With the switch on, followedSurfaceFrame ignores a .top hold,
so no frame moves; the release only drops the stale state. Red tests:
7abac06.

Also the two comments that described only S3's producer hold
(PaneMotionHold.swift, PaneContentAreaView.swift) now say what the switch
does.
… (the new-tab version passed without the fix)

aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress passed at
its red commit 7abac06 (fleet step 1b0702f8dbf54ae7091c8dad). A tab added
new mid-hold hears the follow in PaneContentAreaView.didAddSubview before its
surface joins the spring, so it never takes a hold. The path in the review's
G1 (b) is a parked tab shown again: it is unhidden before the area hands it
the follow in its next layout pass, so its surface joins with the OFF path's
hold at the final size. The test now parks the second terminal at rest,
shows it again mid-spring, presses a second time, and requires one grid per
press and none after the landing.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent): SHIP at c4d6c93

Scope: the full diff from the merge base bb23623917f to the head c4d6c936c92, with the adversarial protocol of skills/cmux-review, REVIEW.md sections 1 and 12, and CLAUDE.md "Before you touch hot paths". The head moved from 5aab4968d19 during the review. c4d6c936c92 changes only aTerminalThatJoinsAKeyHoldGetsOneGridPerPressThroughASecondPress, and I reviewed it too. Three independent reads: this one and two subagents that did not see my findings. I read the frame owner, the presenter, the host, the surface, the grid policy, the key hold (PaneHostView, PaneContentAreaView, PaneContentView+Parking, ScreenContentView+Motion), the changed tests, the Ghostty fork at the pinned SHA 7d43bb68, and the fleet logs. Nobody built, ran Swift tests or started the app for this review.

Result: no P0, P1 or P2 defect in the code. With the switch off (the default, and the only state that Release can have), every changed line is inert. With the switch on, I found only P3 items. Each needs a rare press sequence or lasts one or two refreshes, and none is worse than the switch-off path. One P2 test gap (T5) should be fixed before ready-to-land.

Correctness (switch on only; none blocks)

H1 (P3, DERIVED): the low-prompt bottom hold keeps the final width and the hold's first edges

Where: TerminalHostView+PaneMotion.swift:40-44 (the .bottom branch returns hold.producerFrame(size, in: shown)), PaneMotionHold.swift:26-29, PaneHostView.swift:190-195 (the hold's edges are picked once per hold), PaneHostView.swift:392-399 (the rest change is measured from the size at the start of the hold).

Two sequences, each with a shell prompt in the lower half:

  • (a) A 2x2 grid, p4 at the bottom right. Ctrl-Shift-H on p4: its left edge moves, so the hold is trailing: true until the release. Then Ctrl-Shift-K on p4 before the landing: p4 grows up, so restAt holds the surface at the final size with .bottom. The final width is wider than the area until the landing, and it is on the trailing edge. So the moving left edge cuts off the start of every line, and the kept frame jumps left by the remaining H travel (TerminalSurfaceView+FrameOwner.swift:185-189). This is the m6 defect ("cut off") again. With a first press that shrinks p4 (Ctrl-Shift-L), the same edge leaves a band of terminal ground on the left.
  • (b) A stack. Ctrl-Shift-J on p1 two times, then Ctrl-Shift-K before the landing. The rest is still taller than at the start of the hold, so growing is true (TerminalSurfaceView+PaneMotion.swift:23), and the K press holds the surface with .bottom at the K final size. The area is still taller than that size, so a band of terminal ground shows at the top of the pane until the landing. The doc at TerminalHostView+PaneMotion.swift:28-31 says that the surface "is taller than the pane then, so it still covers it". That is not true here.

Why it matters: both break the m6 contract (top-left, the pane's size) in the F2 path. Both need two presses in one spring. Neither is worse than the switch-off path, which uses the same S3 branch (and in (a) cuts the lines from the H press).

Fix (one change for both): with the switch on, the bottom hold keeps only the height, and only while it covers the area. In followedSurfaceFrame: return CGRect(x: shown.minX, y: shown.minY, width: shown.width, height: max(size.height, shown.height)) (the host is not flipped, so minY is the bottom edge). A single J or K press does not change.

Regression tests (red at this head; PaneContentPolicyTests, the switch on): (a) a 2x2 grid with a low prompt in p4: H on p4, 4 frames, K on p4; in every frame, p4's surface minX is its area's minX. (b) the F2 rig: J, 2 frames, J, 2 frames, K; in every frame, p1's surface is at least as tall as its area.

H2 (P3, DERIVED): after the G1 release, the older hold's request in flight still lands

Where: TerminalSurfaceView+FrameOwner.swift:44-45 (place keeps a request in flight and changes only the anchor), :87 (the request lands with the new anchor), TerminalFramePresenter.swift:243 (the follow-up comes after it).

Sequence: J with a low prompt asks Ghostty for the final size. K comes before that frame shows (a slow renderer, or a program that holds its frames). The release in restAt calls place(area, .top), which only moves the kept frame. Then the J frame lands top-left: a size that the pane did not have in this hold, cut at the bottom, so the prompt is out of view for one or two refreshes. In the usual timing (the J frame shows already), the kept frame moves from the bottom edge to the top-left in one commit, and the text jumps by up to one step for one or two refreshes.

Fix: in the release path, when a request is in flight for a size that is not the wanted size, cancel it and call requestWantedFrame(). The new set_size drops Ghostty's fresh frame of the old size (Metal.zig setHostDrawableSize calls dropFresh).

H3 (P3, DERIVED; decide before the flip): no reflow preview in a key spring, so a low prompt in a pane that shrinks is cut until the announcement

Where: TerminalGridPolicy.swift:88-91 (previewsViewGrid needs final == desired), TerminalSurfaceView.swift:482.

With the switch on, the surface has the area's size in each frame, so desired is not final until the landing, and the preview stays off. Until the daemon announces the press's grid, Ghostty draws the old locked grid, cut to the area. In a pane that shrinks (K on the top pane, J on the bottom pane) with the prompt on the last row, the prompt row is cut until the announcement: one or two refreshes with the local daemon, and more with a remote terminal. With the switch off, the surface has the final size, the preview draws the final grid, and the prompt stays in view.

Why it matters: the prompt can blink at the start of each such press, and the Frames line does not say it.

Fix: name it in the Frames line now, and decide in the flip PR. The choices: accept it and write it in the contract; or keep the surface at the final size while that size is smaller than the area and the cursor is low (the S3 rule for a pane that shrinks); or a Ghostty preview at a given grid (a fork change).

H4 (P3, the same on the base's switch-on path): a bottom hold whose frame waits shows a band at the top

Where: TerminalSurfaceView+FrameOwner.swift:185-189, TerminalFramePresenter.swift:221-224.

Until the final frame shows, the kept start-size frame rides the moving bottom edge, so a band of terminal ground shows at the top of the pane for one or two refreshes. If the program holds its frames for more than keepLimit (6) refreshes, the fallback gives the view the final bounds with the old frame. The old frame draws top-left (contents gravity, IOSurfaceLayer.zig:34), so the prompt is one step above the moving edge, with a band under it. The switch-off path shows a band from the first frame, so it is not better. No change asked here; check it in the flip films.

H5 (P3, contrived): a grid that the F1 rule holds can wait with no follow-up

Where: TerminalSurfaceView.swift:476-478, TerminalSurfaceView+FrameOwner.swift:93.

At rest, a size is in flight and another size is wanted. A report then comes (a font size change), and the F1 rule holds it. If the wanted size goes back to the size in flight before it lands, landRequestedFrame returns no follow-up, and the held grid waits until the next resize or announcement. Fix: after a landing with no follow-up, call updateSurfaceSize() when isHeldGridPending is true and the view is not in pane motion.

G2 (from the review at 447f784c3cf): still open, and the body names it. No change asked.

The G1 fix (5aab4968d19) in every path that calls restAt

  • A press's first frame tick (ScreenContentView+Motion.swift:212-213, before host.frame = placed): the release places the surface at the area of the previous frame (a size that the pane had), and the layout of the same tick places it again. Nothing enters layout or the presenter's pass again. The grid is held (isInPaneMotion), so no grid goes.
  • A later press of the same hold (:233): the same. This is the G1 case.
  • joinPaneMotion from syncWindowState: the branch needs followedHold != nil; otherwise holdSurface runs as on the base. Before updateOcclusion, Ghostty answers HIDDEN, so place only sets the frame. The release calls place as holdSurface does, so the order of calls is the same as on the base.
  • A split, close or zoom that takes over: endKeyResizeHold ends the follow first (ScreenContentView.swift:213-219, PaneHostView.swift:134-141), so restAt holds as on the base.
  • No extra grid: reportFinalGrid runs before the release, and the release's updateSurfaceSize holds the grid.
  • With the switch off, the branch is not taken (it reads the switch), so no frame moves.

Earlier findings

  • F1: fixed (TerminalSurfaceView.swift:476-478). Red cd3f5716, green e6261585. See H5 for one contrived case.
  • F2: fixed for one press, and for a reverse press (G1). H1 covers presses on two axes, and two grow presses with a shrink press after them.
  • F3: reverted. Its guard test passes in both states. The hold that it leaves goes at the next press (G1).
  • T1: partly fixed (P3, as at 447f784c3cf).
  • T2: fixed.
  • T3: the docs say which switch state each test guards. The name aKeptFrameStaysOnTheEdgesItsHoldNames (TerminalSurfaceCommitSizeTests.swift:89) still says "the edges its hold names", and the test checks the top-left (P3).
  • T4: the waits (TerminalLaunchFrameKeeperTests.swift:218, 241) pass at their first check, because that suite never turns the switch on. Their comments ("a refresh or two after the layout", :217, :239) describe the switch-on path (P3).
  • E1: answered. The default is off (TerminalDragTunables.swift:30), and Release and RC never activate the tunable store.
  • G1: fixed (TerminalSurfaceView+PaneMotion.swift:66-74). (a) Red at 7abac06b51d (step 1b0702f8: p1's surface 1188x411 in a 1188x390 area, then down to 372, in all 24 reverse frames). (b) The version at 5aab4968d19 passed at the red commit (step 1b0702f8), because a tab added new hears the follow in didAddSubview before its surface joins. c4d6c936c92 uses the parked-tab path: red at 4e8f53fc46b (step f257aa89: after the landing, the PTY got 80x44, then 88x44), green at the head (step 4e80341d: PaneContentPolicyTests passed, with both G1 tests).
  • The checked items (debugState is debug-only, the perf-hot markers, the contract comments in PaneMotionHold.swift and PaneContentAreaView.swift): done.

Tests

  • T5 (P2, PROVEN; fix before ready-to-land): the default path lost its "one surface size per press" checks. With the switch off, the base checked: PaneContentPolicyTests.swift:135 (sizes.count <= 1), :176 (inFrames.isEmpty), :219 (the reverse press's landing size), KeyboardResizeContentHoldTests.swift:109 (surfaces.count <= 1), and KeyboardResizeReflowAfterLandingTests.swift:96, 98 (no surface layout in the spring or after the landing) (base line numbers). These tests now turn the switch on. The tests that still run with the switch off check only one edge (theSurfaceStaysAtThePaneEdgeThatDoesNotMove with 12 pt slack, KeyResizeFirstTickPinTests), the grids and the rest size (KeyboardResizeOneGridReportTests), or the key's turn. A surface that follows its pane in every frame passes all of them. Why it matters: Release and nightly run the switch-off path. If a later change removes the switch check at TerminalHostView+PaneMotion.swift:40 or TerminalSurfaceView+PaneMotion.swift:66-67, each terminal resizes and draws in every frame of a key resize without the frame owner (the K3 cost, and late frames), and no test fails. Fix: run each changed test with both switch states (@Test(arguments: [false, true]), with a pin that sets .bool(on)), and keep the base's one-size checks for false.
  • The seven changed tests check the m6 contract: the surface at its area's size in each frame, more than 3 sizes per spring, more than 3 frames, and one grid per terminal per press. They failed with the switch off (step 95093a23). They also fail on the base's switch-on path, which held the surface at the hold's size (DERIVED). No loop is empty.
  • The pin (PaneGroundCoversPaneTests.swift:158-168, P3): it is safe where the repo runs these suites. The fleet lane runs one swift test per suite (scripts/ci/package-test-lane.sh:839-867), CI uses width 1 or --no-parallel, the pinning suites are @MainActor and .serialized or have only sync tests, and the async tests turn the switch on again after each await. In one process with several suites in parallel (width 14, or a filter that matches two suites), a pinning test keeps the switch on for other suites during its awaits, and its defer releaseFrames() can turn the switch off under fixtures that turn it on once and then await (TerminalSurfaceCommitSizeTests Fixture, TerminalKeyResizeFrameTests.PaneScreen, TerminalFrameOwnerMainThreadTests). Fix if it causes failures: one TestScoping trait with a process-wide async lock for each test that reads or writes the switch.
  • Coverage (P3): the request-and-keep path in a key spring runs only in TerminalKeyResizeFrameTests (a display worker: one horizontal press, and the F1 case). The other rigs' windows draw nothing, so each size shows at once (TerminalSurfaceView+FrameOwner.swift:50-55). The flip PR's films must include a vertical press, a held key, and a press with its reverse.
  • Comments that state switch-on behavior as a fact (P3): the help: text at TerminalDragTunables.swift:29 does not say that the switch also changes the key resize; TabStripFollowsPaneTickTests.swift:34-36 ("A terminal no longer holds"); the suite header at PaneContentPolicyTests.swift:14-21; TerminalHostView.swift:407 ("The spring landed"), which restAt now also calls at a press.

PR body (correct before ready-to-land)

  • The placeholders @G1RED@, @G1BROAD@ and @G1DISPLAY@ are still in the body, and "the final head 5aab4968d19" is not the head now. The results: step 1b0702f8 failed as intended for aReversePressAfterALowPromptGrowsDownFollowsItsPane, and the old (b) test passed there. Step 62747f12 (5aab4968d19): "All 42 suites passed". Step 840f7370 (5aab4968d19, display): "All 3 suites passed" (TerminalKeyResizeFrameTests: 27 spring frames, misplaced 0, unsized 0, stretched 0, PTY grids per terminal [1, 1]). Steps f257aa89 (red, 4e8f53fc46b) and 4e80341d (green, c4d6c936c92) for the new (b) test. c4d6c936c92 changes only a test, so the two suite steps at 5aab4968d19 are the evidence for the source at the head.
  • "A/B in one launch ... the same app code as this head" is not true now. The A/B ref 6a266873a17 has the app sources of 665b1874e6e (git diff --stat 6a266873a17 665b1874e6e -- Packages/macOS/CmuxNext/Sources is empty), which is before F1, F2 and G1. The decision to keep the default off does not depend on it, but the flip PR must run the A/B at its own head. The list "Files on the per-frame and per-key paths since ..." also does not include G1 (TerminalSurfaceView+PaneMotion.swift, restAt).
  • Frames line, key resize: (1) the low-prompt exception also applies to a pane that grows up (growing: sizeChange.height > 0.5); there the surface keeps its final size on the bottom edge, which does not move. (2) "a strip of the terminal's own ground at the moving edge when it grows": the kept frame is anchored top-left, so the strip is on the right or bottom edge; for a pane that grows left or up, that edge does not move. (3) Add H3.
  • Checked, correct: "Why the default stays off". Job c33bd85d (cmux24, 6 rounds, --key-repeat=6): keys-stress missed OFF 5, 8, 8, 7, 7, 9 = 44 and ON 8, 8, 10, 9, 11, 9 = 55 (ON higher in 5 rounds, equal in 1); mean main time of a motion frame 4.89 and 5.16 ms (+0.27); view layouts per frame 16.90 and 16.88; at most one grid per terminal per press in both arms. The 4-terminal keys layout: 29 and 29 missed.

Checked, no defect found

  • Switch off is the base: the new branches in followedSurfaceFrame (:40) and restAt (:66-67) read the switch. frameOwnership.inFlight is set only when the embedder presents (TerminalSurfaceView+FrameOwner.swift:62, 73), and place clears it when the switch is off (:32-39), so stale is false. Release reads the default false, and the store is inert there.
  • One grid per press with the switch on: one press, a held key, a press and its reverse, a parked or new tab that joins, a second press after a join, and a split, close or zoom that takes over. The per-frame updateSurfaceSize holds while the host is on its spring. At the landing and in the follow-up, the grid is the reported grid. The only forced report in a spring (ownsGeometry) is set only at init in the app.
  • Stretch: none (contents gravity top-left, IOSurfaceLayer.zig:34; kept and landed frames use anchoredFrame). No shell gap: the host paints the terminal's ground over the whole pane.
  • Main thread, per moving terminal and frame: ghostty_surface_set_size on a mirror with a locked grid resizes with tryLock (Termio.zig:832). The renderer mailbox never blocks (renderer/mailbox.zig). setHostDrawableSize asks for no display pass once a frame shows while the embedder presents (Metal.zig). ghostty_surface_host_frame uses only atomics, the fresh-frame mutex and short [CATransaction lock] spans. No sync draw. Copy mode reads Ghostty under its lock only while copy mode is on (TerminalCopyMode.swift:231-232).
  • Debug sampler: in #if DEBUG || CMUX_BENCH_HOOKS (the file, the registration in init, debugState), and called only by MotionFrameTrace at the start of a trace and in its ticks. Its hostFrame(present: false) changes Ghostty state only in the SHOWN state, as the presenter's own query does.
  • The merge of feat: its own diff changes only TerminalKeyResizeFrameTests (the contentPolicyOverride pin), and c642350625a fixes the kept-frame test.
  • Repository rules: no new animation, blocking call, CI job or gate; the new per-frame paths have perf-hot markers.
  • CI at the head: nightly-guards / release-notary is red (a signing-hook guard test; this PR changes none of its files). cmux-next swift test and the fast tier were pending when I read them.

Tally: 34 hypotheses from three independent reads. Kept: 5 switch-on P3 correctness notes (H1 to H5), 1 P2 test gap (T5), 4 P3 test and comment notes, 4 body corrections. Refuted or answered: 20 (a switch-off change, a sampler cost or side effect, extra grids per press, a forced report in a spring, a main-thread wait, a stretch, a shell gap, the pin in the repo's lanes, the order of calls in the G1 release, a vacuous (b) test at the head).

Review (independent subagent): SHIP at c4d6c93

…ecks, with the switch off

The independent review of #35470 at c4d6c93 (T5, P2) found that the
default path (motion.frameOwner.terminals off) lost its checks when the
m6 contract tests turned the switch on. The base's five checks come back
as their own tests, with the switch pinned off after each await:

- PaneContentPolicyTests: aKeyPressSizesEachSurfaceOnceWithTheSwitchOff
  (4 directions), aHeldKeySizesEachSurfaceOnlyInAPressTurnWithTheSwitchOff,
  aPressAndItsReverseRestsAtTheStartSizeWithTheSwitchOff;
- KeyboardResizeContentHoldTests: theProducerResizesOnceWithTheSwitchOff;
- KeyboardResizeReflowAfterLandingTests: theProducerLaysOutOnceWithTheSwitchOff
  (left, right).

They are the base's tests at bb23623 with new names; they guard
behavior that does not change, so they pass on the base and at the head.
PaneGroundCoversPaneTests.ownFrames takes the value to pin.
…s in a key resize

The independent review of #35470 at c4d6c93 (P3 notes) found
comments that state the switch-on behavior as a fact:
- the help of motion.frameOwner.terminals did not say that it changes
  the key resize;
- TabStripFollowsPaneTickTests.Content ("A terminal no longer holds");
- the PaneContentPolicyTests suite header;
- TerminalHostView.releaseSurfaceHold ("The spring landed"), which restAt
  also calls at a press with the switch on;
- TerminalLaunchFrameKeeperTests' resize waits (they pass at the first
  check with the switch off, the default).

Comments and the debug help text only; no behavior changes.
…off and on

The lead (on the review of #35470 at c4d6c93, T5): run the tests with
the switch off and with it on, so both paths are covered. The default
(off) is what ships.

Each test this PR changed that has a meaning in both states now takes
the switch as an argument, pinned after each await:
- PaneContentPolicyTests: the F3 guard (off: at most one new size from
  the frame it shows, then the area's size at the landing), F2 (off: S3's
  bottom hold keeps the same rule), G1 (a) (off: from the reverse press
  each surface keeps one size, p1 its start size), G1 (b) (one grid per
  press in both);
- TabStripFollowsPaneTickTests: the strip-and-terminal test (off: the
  surface takes at most one new size in the press; strips, hosts and
  grids as with it on);
- KeyResizeFirstTickPinTests (both tests), PaneGroundCoversPaneTests (the
  key-press and held-key ground tests), TerminalLaunchFrameKeeperTests
  (the two resize saves).

The m6 contract tests stay switch-on, and the base's one-size checks are
their switch-off counterparts (c0c7f9b). TerminalKeyResizeFrameTests
and TerminalSurfaceCommitSizeTests test the frame owner itself, which
runs only with the switch on.
@cursor

cursor Bot commented Nov 4, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent subagent): SHIP at 6522020

Scope: the full diff from the merge base bb23623917f to the head 6522020512a, with the adversarial protocol of skills/cmux-review. I gave SHIP at c4d6c936c92 (comment 6640562380). In this review I read the three new commits (c0c7f9b0100, 162ea37166b, 6522020512a), and I checked again the code paths that their tests depend on. I compared each restored test with its base version at bb23623917f, and I read the PR body and the fleet logs. Nobody built, ran Swift tests or started the app for this review.

Result: no correctness defect. T5 is fixed, and the order of the lead (run the tests with the switch off and with it on) is met. The P3 items H1 to H5 from my last review are not changed. They are only on the switch-on path, and the body records them for the flip PR.

(c) The source since c4d6c936c92: comments and the help text only

git diff c4d6c936c92 6522020512a -- Packages/macOS/CmuxNext/Sources changes two files:

  • TerminalDragTunables.swift:29: only the help: string. The key, the default (false, :30) and the code name do not change. No generated file copies the help text, and schemas/defaults/cmux-next.json:1465 still has false.
  • TerminalHostView.swift:407-408: only the doc comment of releaseSurfaceHold.

So the code at the head is the code of 5aab4968d19, which I reviewed at c4d6c936c92. The paths that the new tests depend on are not changed: restAt (TerminalSurfaceView+PaneMotion.swift:66-76), followedSurfaceFrame (TerminalHostView+PaneMotion.swift:40-44), the F1 rule (TerminalSurfaceView.swift:476-478), and the key hold (PaneHostView, PaneContentAreaView).

(a) The restored tests check what the base checked

I extracted each test body at the head and at bb23623917f and compared them line by line. The only differences are defer { PaneGroundCoversPaneTests.releaseFrames() } and the calls that pin the switch off:

  • KeyboardResizeContentHoldTests.theProducerResizesOnceWithTheSwitchOff (:125) is the base's theProducerResizesOnceAndTheContainersFollowEveryFrame, with its surfaces.count <= 1 check.
  • KeyboardResizeReflowAfterLandingTests.theProducerLaysOutOnceWithTheSwitchOff (:119, left and right) is the base's theProducerLaysOutOnceAndTheContainersEveryFrame, with surfaceMoves.isEmpty and "no layout after the landing".
  • PaneContentPolicyTests.aKeyPressSizesEachSurfaceOnceWithTheSwitchOff (:455, 4 directions) is the base's aKeyPressSizesEachSurfaceOnceAndSendsOneGrid, with sizes.count <= 1.
  • aHeldKeySizesEachSurfaceOnlyInAPressTurnWithTheSwitchOff (:494) is the base's aHeldKeySizesEachSurfaceOnlyInAPressTurn, with inFrames.isEmpty.
  • aPressAndItsReverseRestsAtTheStartSizeWithTheSwitchOff (:543) is the base's aPressAndItsReverseInOneHoldSendEachGridInItsTurn, with the landing-size check.

These checks discriminate. With the switch on, they failed at c642350625a (step e9e6a30c3d9effb058d77e64): "p1's surface (the producer) took 17 sizes in one press", "a surface laid out in a frame of the spring", and "p1's surface took 17 sizes in one press". So a change that makes the default path follow its pane in each frame fails these tests. The body says correctly that they pass on the base and at the head: they are restored coverage, not a fix, so no red step applies.

(b) The both-state tests

The pin: ownFrames(_ on:) (PaneGroundCoversPaneTests.swift:160) sets .bool(on). TunableStore.set returns the value that it stored (TunableStore.swift:171-178), and a toggle keeps its value when it is clamped (TunableValue.swift:53-54). So #require(ownFrames(false)) passes only when the override is false. Each test has defer { releaseFrames() } before its first throwing point. Each async test pins the switch again after each await and before its next press or frame. Each sync test pins it once and does not await.

Per test, with the switch off:

  • F3 guard (PaneContentPolicyTests.swift:263): at most one new size from the frame the terminal shows, and the landing at the area's size. A loop with no frames cannot pass, because frames > 3.
  • F2 (:315): S3's bottom hold has the same rule with the switch off, so taller > 3 and cut.isEmpty are the same checks in both states.
  • G1 (a) (:349): from the first frame of the reverse press, each surface keeps one size, and p1's size is its start size (:388-390). That is the switch-off form of "the newest press's anchor wins" (the S3 hold at the rest size).
  • G1 (b) (:400): one grid per press, and no grid after the landing. With the switch off, restAt replaces the hold at each press, so this is the base rule.
  • TabStrip (b) (TabStripFollowsPaneTickTests.swift:266): the strips and the hosts follow in each frame in both states. With the switch off, each surface takes at most one new size in the press, and the surface has its host's size at rest and after the landing.
  • KeyResizeFirstTickPinTests, PaneGroundCoversPaneTests (key press and held key), TerminalLaunchFrameKeeperTests (two resize saves): the same checks in both states. They are correct in both states, because the edge, the ground, the grids and the save do not depend on the switch.

None of these is vacuous: each has a frame count bound, a #require on the case under test, or a size count. Step 167bfcdf2e06625c1688bc1d at the head ran each new case. For example, aParkedTerminalShownDuringAKeyHoldFollowsFromItsFirstFrame(switchOn:) with 2 test cases passed, aKeyResizeKeepsEachStripAndTerminalOnItsPaneInEveryFrame(direction:switchOn:) with 8 test cases passed, and aKeyPressSizesEachSurfaceOnceWithTheSwitchOff(direction:) with 4 test cases passed.

(d) The PR body

The body is now correct on each point that my last review asked for:

  • It names the A/B ref and the age of its code (6a266873a17 = the app sources of 665b1874e6e).
  • G1 is in the list of files on the per-frame paths.
  • The Frames line has the grow-up case, the edge of the ground strip, and H3.
  • It records H1 to H5 for the flip PR.
  • It records the G1 steps, with the red and green steps of the new (b) test.

One item is open. The body has the result of the 42-suite step at the head, and it agrees with the log. But it still says "Display step d33333ef60fbc8bb4250b0f0 runs now; its result replaces this sentence". That step is finished (below). Put its result in the body.

(e) T5 and the order of the lead: met

The default path again has tests that fail if a terminal surface takes more than one size in a key press. Each other test that this PR changed, and that has a meaning in both states, runs with both states. The m6 contract tests stay switch-on, and their switch-off forms are the five restored tests. TerminalKeyResizeFrameTests and TerminalSurfaceCommitSizeTests stay switch-on. That is correct, because they test the frame owner, which runs only with the switch on.

Notes (P3, none blocks)

  • N1: aTerminalThatJoinsDuringAHoldFollowsAndRestsAtTheFinalSize (PaneContentPolicyTests.swift:595) does not pin the switch. It runs on whatever value the store has, which is the default (false) in the repo's lanes. Its checks (the host follows, the rest size, one grid) are the same in both states. If you want it to follow the order of the lead too, give it the [false, true] argument.
  • N2: the pin is still process-wide (my P3 at c4d6c936c92). The new switch-off guards make it more important. In a run that has more than one suite in one process, in parallel, another test's ownFrames(true) during an await could make a switch-off guard fail. Such a guard can fail, but it cannot pass for a wrong reason. The repo's lanes run one suite per process, or with width 1, so this is not a problem today.
  • N3: the defaults guard check is red at the head. It needs a SHIP receipt at the exact head because the help text changes the fingerprint of the tunable source (TerminalDragTunables.swift:framesOwnedByCommit). The default value does not change. This comment is that receipt. A receipt comment re-runs the guard (cmux-next-defaults-guard-review.yml on issue_comment). If the check stays red after this comment, re-run it.

Fleet steps at this head

  • Step 167bfcdf2e06625c1688bc1d (the 42 terminal suites, 6522020512a): done, exit 0, "All 42 suites passed". It includes each new case, in both states.
  • Display step d33333ef60fbc8bb4250b0f0 (6522020512a): done, exit 0, "All 3 suites passed" (TerminalKeyResizeFrameTests, TerminalFrameOwnerMainThreadTests, TerminalSurfaceCommitSizeTests). TerminalKeyResizeFrameTests: 27 spring frames, misplaced 0, unsized 0, stretched 0, PTY grids per terminal [1, 1]. Its main time per frame was p50 0.32, p95 1.29 and max 13.21 ms. At 5aab4968d19, with the same app code, the max was 0.89 ms. So that one slow frame comes from the host (another display worker), not from this head. The test prints this time but does not check it.
  • CI at the head: cmux-next swift test was pending when I read it. defaults guard was red (N3).

Checked, no defect found

  • The switch-off path is the base's path at this head too (the source diff since c4d6c936c92 changes comments and a string only).
  • One grid per press in each path that the both-state tests cover, with the switch off and with it on.
  • No test leaves the switch set after it ends (each defer runs before the rig closes, and each test pins the value it needs again after each await).

Review (independent subagent): SHIP at 6522020

@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 4, 2026
@lawrencecchen
lawrencecchen merged commit 8255dfd into feat-cmux-next Nov 4, 2026
266 of 270 checks passed
@lawrencecchen
lawrencecchen deleted the fix/frame-owner-s4-on branch November 4, 2026 21:49
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Swift logic follow-up (tip watcher swift-logic row, STATUS 13027 "SWIFT LOGIC CULPRITS"; lead order).

This PR raised swift_logic_lines by +49 at its merge commit 8255dfd: scripts/cmux-next/swift-logic-lines.py before and after the merge, with the same table (the tip's, c6be943). The largest declarations of the rise:

  • extension TerminalSurfaceView in Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+MotionSample.swift: +49 lines counted as Swift logic (no row in plans/cmux-next/swift-logic-classes.tsv)

plans/cmux-next/layer-ownership.md says Rust owns every state, list and rule and Swift is the platform shell. Please move the new logic of this PR in a follow-up: to Rust (the crate that owns the area; the owner lists in STATUS 12147-12165 name the target per area), or at least out of the app into its package. If a declaration above is platform code (AppKit, Core Animation, OS calls, strings, tokens, DEBUG tooling), reply here and the table gets a PLATFORM row with your reason instead.

The swift-logic row is a report, not a gate: nothing blocks on this comment.

lawrencecchen added a commit that referenced this pull request Nov 5, 2026
…'s width and never leaves a band (#35914)

* test(next): S4 H1: a low prompt's bottom hold keeps its pane's width and never leaves a band above the terminal (red)

Review of #35470 at c4d6c93, H1 (switch on), two sequences:
- a 2x2 grid, Ctrl-Shift-H on p4 then Ctrl-Shift-K before the landing (p4 grows
  up with its prompt in the lower half): the bottom hold kept the final width
  on the hold's trailing edge, so the moving left edge cut off every line;
- Ctrl-Shift-J on p1 twice, then Ctrl-Shift-K: the K press kept the bottom hold
  at its final size, shorter than p1's area mid-spring: a band of ground above
  the terminal until the landing.

* fix(next): S4 H1: with the switch on, a low prompt's bottom hold keeps only its height, on its pane's width

Review of #35470 at c4d6c93, H1 (switch on only): the bottom hold that
keeps a low prompt in view (F2) placed the press's final size on the hold's
first edges. After a press on the other axis (H, then K on a bottom-right
pane) the final width sat on the trailing edge and the moving left edge cut
off every line; after two grow presses and a shrink (J, J, K) the K press's
final size was shorter than the area mid-spring, so a band of ground showed
above the terminal.

followedSurfaceFrame now keeps, with the switch on, only the hold's height,
and only while it is taller than the area: the bottom on the area's bottom
edge, the area's leading edge and width. A single J or K press does not
change. The switch-off path is unchanged.

* test(next): S4 H1 review: a kept frame of a low-prompt hold not taller than its area stays top-left (red); the band test proves its bottom hold happened

Review of #35914 at 03c050e (BLOCK, item 1): with the switch on, followedSurfaceFrame gives the area when the
bottom hold is not taller than it, but the surface's anchor stays .bottom, so a frame kept while a new size waits rides
the bottom edge: its top rows are cut and the text moves with the bottom edge. TerminalSurfaceCommitSizeTests draws, so
it sees the kept frame. Item 2: the band test now expects the bottom hold to happen.

* fix(next): S4 H1 review: the surface's anchor comes from the same decision as its frame

Review of #35914 at 03c050e (BLOCK): with motion.frameOwner.terminals on, a low prompt's bottom hold that is not
taller than its area gave the surface the area's frame, but the frame owner still got the hold's .bottom anchor, so a
frame kept while a new size waits (1 to 4 refreshes in a window that draws) rode the bottom edge: its top rows were cut
and the text moved up by the height the area lost, or a band of ground showed above it while the area still grew.
followedSurfaceFrame now returns the frame and its anchor (.bottom only while the hold is taller than the area), and
install, holdSurface, releaseSurfaceHold and layout() place the surface with that one result. Switch off: the same
frame as before; place does not read the anchor. The restAt doc says what the switch-on host keeps.
lawrencecchen added a commit that referenced this pull request Nov 5, 2026
…erminal'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.
lawrencecchen added a commit that referenced this pull request Nov 5, 2026
…ane's size with its prompt in view (m14, no low-prompt hold) (#36199)

* 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.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 65220205 Deployed Nov 4, 2026 by lawrencecchen via namespace autofix #14578
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.

1 participant