cmux NEXT panes: closing the right column leaves in the key's turn, never after the tab's close animation
Leo's words (2026-10-25)
i also feel like it's weird how closing the right pane seems be like... waiting for the right tab's closing animation to PLAY before closing...? we should never block shit on the tab animation...
The chain
The "right pane" here is a column's only pane: the terminal column to the right of a docked chat, or the second column of the strip (the main-pane tour's "Closing the right column gives the chat its full width"). A pane with a split sibling already left in the key's turn. A column's only pane did not:
- Cmd-W / the close button →
CloseUndoToasts.close→PaneController.prepareClose(CmuxNextApp/PaneController+Intents.swift:251): the store close intent drops the tab, andapply(snapshot())hands the strip an empty tab list. Withui.animations.newTabon (the default since 528b9e93de0),TabStripView.sync(CmuxNextTabs/TabStripView+Sync.swift:117-125) keeps the tab asdyingand plays thetabClosetween (200 ms). prepareClosethen callsshowPaneClose()(PaneController+Intents.swift:256), which goes throughLayoutModel.showPaneClose→removingSplitPane(CmuxNextLayout/Model/LayoutModel+PaneClose.swift:70-80). That returns nil for a column's only pane (columns[index].root.removing(pane)is nil), so nothing is hidden and the focus does not move.- The emptied column stays on screen, with its strip playing the tab's close, until the daemon's tree without the column comes back (
LayoutModel.apply(screens:), a round trip, longer under load). Only then does the column leave and its neighbour spring. Close Pane (PaneHandlers+Verbs.swift:52) hit the same nil.
This came in with bca04e862d8 (lawrencecchen, "cmux-next close motion"), which left the column case to the daemon on purpose (its DESIGN.md "Doubts": "A column's only pane ... still leaves with the daemon's tree ... Next step if the round trip shows there."). It shows. This PR is that next step, as a fix-forward. No state now waits on the tab tween or the echo; the tween still plays on its own strip cell, which nothing waits on.
The fix
LayoutModel+PaneClose.swift:
removingSplitPanenow removes a column's only pane together with its column, as long as another column stays (removingColumn(at:from:)). It reproduces the daemon's own projection, so the echo matches what is already on screen:project_layout_columns(a lone one-row column becomes the screen's tree, a lone pinned dock keeps its dock and width,pinned-dock-alone-v1) andnormalize_dock_columns.- A close's sizes are worked out from scratch, not stored per split or column: even split ratios under
layout.splitSizing(re-review fix 3) and column widths underlayout.newColumnWidthfitScreen (review fix 2). What shows is: the daemon's widths, then the sizes of closes that already landed while another close was still pending (landedCloseRatios,landedCloseWidths), then each pending close in the order it was shown, with the sizing it leaves (CloseSizing.splitChangesandcolumnResizes) computed against the layout before it (overlayingPaneCloses). So overlapping closes, whether in one split chain or across columns, always show what the still-pending closes leave, whether their echoes arrive in order or out of order, or one is refused. The sizes go to the daemon once, when the screen's last pending close resolves (flushLandedCloseSizes). Closes that land in one snapshot are sized one by one in show order against a running tree (noteLandedCloses), so adjacent closes, each one's sibling gone too, still record their ratios.applyCloseSizingno longer sizes from the raw daemon trees for a close this window showed. A shown close no longer holdssplitOverrides; it supersedes sent but unacknowledged ratios and widths of the splits and columns it resizes.
PaneController.showPaneClose (review fix 1): when the closed pane had the focus, it sends the focus coordinator the held topology minus that pane (FocusTopology.removing(pane:)). The focus reducer then picks the successor the same way as for any close: layout.closeFocus plus this window's focus history, per close-focus.md. That is the pick the echo's topology would make, so the echo leaves the focus alone. The layout shows the reducer's pick in the key's turn. The explicit .focusPane(successor) is gone; LayoutModel.successor is now only the model's stand-in until the reducer answers. The doc comments and DESIGN.md are updated. Lawrence's spring and perf design is unchanged: one layout pass, one spring from the key's frame, terminals resize at the spring's start, no extra apply or topology (PaneCloseApplyTests).
Tests (fleet)
New in PaneCloseMotionTests:
aColumnsOnlyPaneLeavesWithItsColumnInTheKeysTurn: chat dock + two columns, close the right column's only pane. In the key's turn: the focus is on p1, p2 is out of the model and has no host, c1's width is final (1.0), and p1's target is final. p1 grows in the first frame and lands on that target.theDaemonsEchoesOfAColumnCloseChangeNothingOnScreen: the emptied-column echo and then the column-closed echo retarget nothing. One width intent goes, after the tree lost the column.aRefusedColumnCloseBringsTheColumnAndItsWidthsBackhomesColumnBesideThePinnedDockLeavesInTheKeysTurn(pinned dock stays alone)theColumnsLeftTakeTheDaemonsShapeInTheKeysTurn(lone column → splits; docks with no scrolling column scroll again)aColumnCloseSetsItsFinalLayoutInTheKeysTurn(the measurement below)aPaneWithNoSiblingLeavesWithTheDaemonsTreeis renamed totheScreensLastPaneLeavesWithTheDaemonsTree. Its oldstrip.close("p1") == nilassertion pinned the behaviour this PR changes.
New in PaneCloseControllerTests, through the real WorkspaceContentController, its pane controllers, its focus coordinator and the store's echoes (review: the first tests bypassed the coordinator and only had one pending close):
theFocusGoesToTheLeftColumnsMostRecentPane: a left column [p1, p3] with p3 the most recently focused. Closing the right column focuses p3, and the echo keeps it there.theFocusFollowsTheCloseFocusSetting(policy:expected:): three columns, focus history c13 then c12, close c12. With previousNeighbor the focus goes to p1; with mostRecent it goes to p3. The echo keeps it.- With a chat dock plus three columns at 1/3 under fitScreen, close c13 then c12 before either reply, then:
overlappingClosesKeepTheirWidthsThroughOrderedEchoes: c11 stays at 1 through both echoes, and onec11=1intent goes, after the last echo.overlappingClosesKeepTheirWidthsThroughOutOfOrderEchoes: the same, with c12's echo arriving first.aRefusedSecondCloseBringsBackTheFirstClosesWidths: c11 = c12 = 0.5, and those widths go out once, at the first close's echo.aSecondCloseRefusedAfterTheFirstLandedSendsTheFirstClosesWidthsaRefusedFirstCloseLeavesTheSecondClosesWidths: c11 = c13 = 0.5.
- Re-review interleavings:
aCloseBeforeTheLastClosesWidthsAreAcknowledgedOwnsTheWidths: c13 closes and its echo lands, which sends c11 = c12 = 0.5. Before the daemon acknowledges those, c12 closes. c11 takes width 1 in that key's turn and keeps it through the acknowledgement and c12's echo. Fix: a shown close under fit-screen drops the sent, unacknowledged width overrides of the columns it resizes or removes (supersedeSentWidths).aRefusedLaterCloseInOneChainKeepsTheEarlierClosesRatios: p1 | p2 | p3 | p4 in one chain; p4 closes, then p3. p3's close is refused while p4's is pending. The chain keeps p4's close sizing (s1 1/3, s2 1/2), not the daemon's quarters, until p4's echo; then the ratios go out once.overlappingClosesInOneChainLandingInOneSnapshotEndEven: p4 then p3 close, and both echoes arrive in one snapshot. The chain ends even (s1 1/2), and onesplit:1=0.5intent goes.overlappingClosesInOneChainKeepTheirRatiosThroughTheirEchoes: both closes accepted. s1 stays at 1/2 through both echoes, and onesplit:1=0.5intent goes, after the last echo.aBackgroundCloseLeavesNoHiddenPaneForTheNextClosesFocus: columns A/B/C with C focused. B closes in the background, then C closes before B's echo. The focus goes to A, never to the hidden B, and the coordinator and the layout agree through both echoes. Fix: every shown close, focused or not, takes its pane out of the coordinator's held topology.
The tab half of the coordinator's spec (closing one tab of several switches the selected tab and content at once) already holds on base. prepareClose applies the strip and selection synchronously, and the tween runs on the dying cell only. No change there.
- Red on base (62b3f90, tests only): fleet step
ee074cdcc65962f6dd0adda3. The 6 new tests fail (close("p2") → nil,finalAt → 12,focusAt → 12) and the 11 existing ones pass. - Review fixes, red (0cee0a3: the new controller tests on top of the first fix): fleet step
593e762fce7a8f92a74e60ef. Every new controller test fails as the review described, with no crash:- With two overlapping closes, the first echo resized c11 to 0.5 and sent
c11=0.5while a close was still pending, thenc11=1.0(ordered and out-of-order echoes alike). - A refused second close left c11 at 1/3.
- A refused first close left c11 at 1 and c13 at 1/3.
- The focus went to
pane:1instead of the most recentpane:3, andmostRecentwas ignored.
- With two overlapping closes, the first echo resized c11 to 0.5 and sent
- Review fixes, green (c2299b2): fleet step
795306b0d0707baef2bf235d. All 11 suites pass: PaneCloseControllerTests, PaneCloseMotionTests, PaneCloseApplyTests, PaneCloseCanvasTests, ColumnCloseMotionTests, ColumnCloseScrollMotionTests, FocusMoveRingFlashTests, EarlyTerminalGridTests, TerminalPaneMotionFinalGridTests, PaneBurstMotionTests, PinnedHomeDockTests. The click-to-final-layout measurement is unchanged (final target at frame 0, focus at frame 0, at rest at frame 39). - Re-review fixes, red (b8e20f3: the two new tests on top of c2299b2): fleet step
60595d023ccfe0b5ec377a6d.- c11 stays at 0.5 until the older widths' reply, and the acknowledgement keeps it at 0.5.
- The coordinator still holds the hidden B, and the focus goes to
pane:2.
- Re-review fixes, green (6560305): fleet step
b78042f8f194db6ee0995428. All 11 suites pass, and the measurement is unchanged. - Split-chain fix, red (e096969: the two chain tests on top of 6560305): fleet step
e041f624ac855211c3ec75fb.- The refusal lost p4's sizing: s1 fell back to 0.25.
- With both closes accepted, the first echo resized s1 to 1/3 and sent
split:1=0.3333andsplit:2=0.5while a close was pending, thensplit:1=0.5.
- Split-chain fix, green (c641532): fleet step
ac54fb17b6e44be12390822b. All 11 close suites pass, and the measurement is unchanged. The step also listed two suite names that do not exist (SplitSizingTests, EvenSplitRatiosTests, 0 tests); those are not failures of this change. The real split and divider suites ran in stepce540458657f542f1e019bcf: SplitSpringMotionTests, SplitTerminalResizeCountTests, HiddenScreenMotionTests, DropPreviewLandingParityTests, BonsplitDropParityTests, DividerClickKeepsRatioTests, RowCloseMotionTests and HomeChiefPaneUncloseableTests all pass. - One-snapshot fix, red (9b336fc: the new test on top of c641532): fleet step
14b3c1028966b489659e7b85. s1 stayed at the daemon's 0.25 and no ratio intent was sent. - One-snapshot fix, green (39e4c0e): fleet step
80dc1346f7123b17d932287e. All 19 suites pass (the 11 close suites and the 8 split and divider suites), and the measurement is unchanged. - First round, green (bb73e2b): fleet step
b18f6b4d5c7705e0120985d3(PaneCloseMotionTests plus the neighbouring close, column, grid and burst suites): all 10 suites pass: PaneCloseMotionTests, PaneCloseApplyTests, PaneCloseCanvasTests, ColumnCloseMotionTests, ColumnCloseScrollMotionTests, FocusMoveRingFlashTests, EarlyTerminalGridTests, TerminalPaneMotionFinalGridTests, PaneBurstMotionTests, PinnedHomeDockTests.
Click to final layout (same harness, 120 Hz frames, daemon echo at 100 ms)
aColumnCloseSetsItsFinalLayoutInTheKeysTurn, closing the right column:
| final target width of the left column | focus on the left column | emptied column on screen | |
|---|---|---|---|
| before (base) | frame 12 (the echo, ~100 ms; later under daemon load) | frame 12 | until the echo, showing the tab's close tween |
| after | frame 0 (the key's turn) | frame 0 | never |
Measured: base column close: final target at frame 12, focus at frame 12, at rest at frame 50; this PR column close: final target at frame 0, focus at frame 0, at rest at frame 39. Click to final layout and focus goes from 12 frames (100 ms, the echo) to 0. Click to rest goes from 50 to 39 frames (417 ms to 325 ms).
Risk
Medium-low and contained to a column's only pane in showPaneClose. If the local projection disagrees with the daemon's tree, the echo retargets once, the same as before this PR. A refused close (for example the daemon's dock-column-pinned) brings the column and its widths back. Not covered: a column with rows (still left to the daemon), and a refused close's superseded sent ratios or widths (one echo, as above). If a close that superseded sent widths is refused, the daemon's older widths show until those sent widths are acknowledged (one echo).
Before/after captures
Fleet builds: before = merge base 3e70949d (job 6ce5631fb42aa8dc2af25461), after = this PR's head c2299b22 (job 67c05afa75bef6a8111beb88). Capture minis cmux12s (before) and cmux8s (after). Steps: a terminal workspace, New Column, then Cmd-W on the right column's only tab. The window was recorded every display frame around the key (debug.new_tab frame_strip, half scale). The frames are the app's own composited window, and their text was checked for private data.
| ms after Cmd-W | Before (3e70949d) | After (c2299b22) |
|---|---|---|
| ~15 / ~12 (the frame before the close shows) | ![]() | ![]() |
| ~35 / ~36 | ![]() | ![]() |
| at rest (~62 ms on base) | ![]() | (same as 36 ms) |
Before: at 35 ms the emptied right column is back on screen. It has only its tab strip and no content, and the left column hasn't grown, because the column waits for the daemon's tree. The layout settles only at ~62 ms, after a one-frame flash at 25 ms. After: the next frame (36 ms) is already the final layout, with the left column taking the full width and no emptied column at any point.
On an idle capture mini the daemon answers in ~20-60 ms, so the before window is short here. It grows with daemon load, as the test numbers above show (frame 12 = 100 ms with the echo at 100 ms). Close Pane was not recorded separately: on the base build, a Close Pane of the right column in the agent-chats layout was refused (dock-column-pinned), and in this terminal layout it takes the same path as Cmd-W.
Stills after the steps (settled):
| Before | After | |
|---|---|---|
| Light | ![]() | ![]() |
| Dark | ![]() | ![]() |
Agent session: huge-blue
🤖 Generated with Claude Code








