cmux-next: resize keys move the divider in the arrow's direction in a strip of columns and at a side dock; one press is one undo step
Summary
Resize Pane Left/Right (Ctrl-Shift-H/L, Ctrl-Cmd-Left/Right) on a scrolling strip column with no horizontal split grew or shrank the focused column on its own. On the last column the strip kept that column at the window's edge, so its left edge (the divider a person sees) moved against the arrow, and on a middle column every column after it moved too. A right dock grew with Right, so its inner edge moved left.
Now one press moves exactly one divider one step toward the arrow, as Ghostty's resize_split does, and changes only the two sides of that divider (lead decision, STATUS 8923; Lawrence asked the lead to decide):
- Strip column: the divider is the column's right edge when a strip column follows it, else its left edge (the last column). The two columns beside it trade width, as an Option-drag does: the columns after them and the strip camera stay. The press keeps the left column of the pair in place and reveals nothing (
ColumnScrollState.keptColumn, for the press's own sync only). - The trade is pixel exact (
ShownResizeRange.trade): it moves whole device pixels from the widths the columns show (a column below its minimum shows at the minimum rounded up to a pixel), keeps the pair's ideal sum so the columns after it and the cut of a column that ends at the window's edge do not change, sends the widths as the daemon keeps them (f32, so its echo moves nothing), and places the result before it sends: exactly the divider moves by the step, or the press is refused. It stops at a column's minimum, the widest share, and the edge of the strip the window shows. - One column alone moves its right edge, as before. A screen whose only column is a dock shows it full width: no press moves it, so the press is refused.
- Side dock: its inner edge moves with the arrow (a left dock grows with Right, a right dock shrinks with Right), inside the widths it shows (
ShownResizeRange.dockWidth: its minimum to its share of the window, and a docked dock to the room the strip leaves). - Splits, rows, top/bottom docks: unchanged.
A press (and a drag's release) is one change, all or nothing (EndedValues):
- Checked before anything moves: every value has its target, and the app can send it now (
LayoutModel.sendsNow, wired fromLayoutHandleMap.sendsNow: a pane the daemon has not made yet, a stand-in, refuses commands). A trade beside a column still being made is refused at once, as other pane verbs on a stand-in are. - Shown in one turn, all replies counted before the first command, and the transaction settles at the reply to its last value (
TransactionReplies), so a daemon snapshot between a trade's two events no longer shows a column at its old width for a frame. This was true of Option and Command drag releases before this PR too. - A refusal is noted where it happens (
LayoutModel.rejectTransaction), per send scope, so it stops the rest of the change even when another change is sent from inside it; a change the app answers and applies in its own turn counts as sent. - Sent under one daemon transaction number (
GestureTransactionNumbers, kept while any command of the transaction is in flight): the daemon keeps one undo entry per transaction for widths and ratios (cmux-tui-corelayout_undo_coalesces_every_target_in_one_resize_transaction), so Undo Layout Change takes a press back in one step. A dock pin with a width is still two steps (the daemon keeps a dock change apart).
Where the rule cannot hold (not changed here):
- Two docked side docks whose widths pass the room the strip leaves share it in proportion (
DockStripGeometry.place): a press on one there moves both inner edges. - A divider under an overlay dock's glass moves under the glass until it shows.
- All or nothing holds in the app, not at the daemon: two values are still two daemon commands, and if the daemon refuses the second after it applied the first, the first stays applied. Atomic multi-value commits wait for layout op batches (resize-modifiers.md); the same is true of every Option and Command drag release on feat today. Codex judged this an acceptable limit for this PR.
shownResizeRange and keyboardResizeWillChange moved from LayoutRootView.swift (over its file budget) to LayoutRootView+KeyResize.swift. Specs: PaneResize's doc comment, plans/cmux-next/terminal-shortcuts.md (Resize pane), plans/cmux-next/column-scroll.md (L6). The keys do not change (no Ghostty-defaults trailer). What's New: release-notes/next/highlights/resize-keys-arrow-direction.md.
Testing
Command (every red and green below):
cmux-ci run --class light --script scripts/ci/package-test-lane.sh --ref <sha> \
--arg=suite --arg=Packages/macOS/CmuxNext --arg=<suites>
Tests (one new file, KeyboardResizeArrowDirectionTests.swift): a real LayoutRootView with a manual frame tick (every pane's shown frame before and after a press, every frame of a press's spring, the intents sent); geometry walks at 1x and 2x on odd-pixel strips with the daemon's f32 echo; the layout model's settle and reject paths; the workspace content controller (daemon transaction numbers, a stand-in column).
Red first, each in its own commit:
- 68b7d122e8c2, the base behavior: step
bec924e7d90dc1e88a29d9f4FAILED, 8 of 10 tests (Right on the last column moved its left edge 60 pt left; the press scrolled an overflowing strip; a press sent 1 width, not 2; one press took 2 daemon transactions). Scratch ed71b453723e (the window-edge test's corrected setup on the base):7e70250dd78848c95adbac43FAILED. - c7fa1168c31c (Codex review 1: a trade from a column shown at its minimum moved an outer edge 1 px; a dock at its cap and a lone dock took dead presses):
137cf14f85b0a76f84a9956aFAILED, those 3 tests. - 2b6dc4d4b7c4 (Claude review: odd-pixel strips moved the last column's edge at the press and at the echo; the first reply settled the whole trade; a refused press kept its camera anchor):
21fb1fb9f7857cc48d484480FAILED, those 3 tests. - d0a50f6d9ba0 (Codex review 2: a refused first width still sent the second; a reply in the first width's turn settled the trade; a trade beside a stand-in sent half):
c047c08d7e8550f6cf1da57aFAILED, those 3 tests. - 46efe549b4b0 (Codex review 3: a change the app answered and applied in its own turn read as refused):
91803501701bcd916ce1b131FAILED, that test. - 6f32972184d0 and 3eefb96da39d (Codex review 4, new file
EndedValuesTests.swift: a change sent inside another lost the outer refusal; the daemon number was split by a change between two values):7763e315d73f12efb5108f25FAILED, those 2 tests. - a448d7f4d411 (Codex review 5: the same transaction sent inside itself; a number window of 32):
69b32b438358729f2a1af32dFAILED, those 2 tests.
Green at c4b8d663930b (the last commit of the PR's own; the head is a merge of feat after it, with the PR's own diff unchanged line for line): step 55e05374fffc06fe516dcc22 (aws-m4pro-1) PASSED, 31 of 31 suites: every new suite, every keyboard-resize suite, the column scroll and close suites, and every divider-drag suite (the release path goes through EndedValues too: DividerModifierDragTests, DragSendsOneIntentTests, DividerDragFollowsPointerTests, DividerDragFrameTickTests, DividerSnapTests, SplitSpringMotionTests, PaneCloseMotionTests).
Green at the head aae372817d1: step 221417dc6bc2d3f47163bf50 (aws-m4pro-3) PASSED, 31 of 31 suites. At c4aaf3d0366 (an earlier feat merge) the same 31 suites, step e4f1bca8cbd13900d50cd231, did not run any test: the lane's web bundle step failed on a feat base red (schemas/settings/settings-schema.json, generated, still names the settings.cloud.tunnel.* strings #34198 removed; 63 strings missing; the base autofix's Mac exports regenerate it). It runs again when feat is green.
GUI proof on a display worker (--display --label gui-session-v1, CMUX_TEST_REQUIRE_GUI=1, 17 suites): at c4b8d663930b step a62d9b2f7eb83f2e19cc9ac5 (cmuxs-Mac-mini-4) PASSED, 17 of 17 suites, 0 skipped; at c4aaf3d0366 step 89fa3b3e1b662eba8b923bf2 ran no test (the same base red); at the head aae372817d1 step 4de124a758d68f38a0d9e6e7 (cmux-austin-mini-0) PASSED, 17 of 17 suites, 0 skipped.
Cost of a press (fleet steps 78d133c3ca375a21b3551429 and 5b77dcf33761de158cb31686, a scratch measurement not in the PR, 2560x1440 at 2x, 300 runs): one key trade's PaneResize.change costs about one ScreenGeometry.compute of the screen: p99 0.087 ms at 8 columns (32 panes), 0.302 ms at 32 columns (128 panes), 0.568 ms at 64 columns (256 panes), against the 10 ms action budget.
Films (the refactor owner, pixel-perfect): cmux24, 120 Hz 2x, real HID keys, judged per press from the app's input journal and the trace (40 ms before the key, 600 ms after). Verdict GREEN.
- At the head aae372817d1 (build bd90b3c7, with S3 #34170 under it): rk-cols2 5 of 6 presses move one divider (column:31) 57.5 pt in the arrow's direction, the first L refused at the left column's maximum width; rk-cols4 6 of 6 move one divider 57.5 pt the arrow's way; resize-keyboard (splits) 4 of 4 unchanged.
- At the PR code c4b8d663930 (build 62cecf7e, the PR plus #34198): the same results; the last column's right edge never moved.
- Earlier, at 3f079dc47050 and 7dc25a2a2dab: the same per-press result.
- Base, FEAT DOGFOOD BUILD 8e149f2e on the same mini: rk-cols2 6 of 6 presses moved the edge against the arrow; rk-cols4 every press moved three edges.
- Not filmed: a press beside a pane that is still being created; its unit test (
aTradeBesideAColumnTheDaemonHasNotMadeIsRefusedAndSendsNothing, the real content controller) is the evidence.
TYPING UNDER LOAD (pixel-perfect, typing-load-l6 on cmux18), key-to-commit ms, p50 / p95 / p99 / max:
- base FEAT DOGFOOD BUILD 8e149f2e: 3.16 / 9.12 / 13.51 / 33.1
- PR code c4b8d663930: 3.42 / 9.14 / 12.98 / 19.4 (PASS)
- head aae372817d1 (build bd90b3c7): 3.35 / 9.31 / 14.77 / 44.7 (PASS; the 44.7 ms key is the typist's first Ctrl-C, a base finding in every build today, STATUS 10240)
- Today's noise band: p99 13.39 to 16.91 ms over 10 runs.
Reviews: the Claude review subagent said FIX at 3f079dc47050 (the pixel and settle findings, fixed above). Codex (a different model) said FIX at 3f079dc47050, 7dc25a2a2dab, 035fa4604928, bf4dfe9891a7 and d307c16972b5 (each fixed red-first above), SHIP at c4b8d663930b, SHIP at c4aaf3d0366 (it checked the merged S2 frame-tick drag path against EndedValues), and SHIP at the head aae372817d1 (a merge of feat; it checked what the merge brought into the layout packages). Receipts in the PR comments.
Observed / Not run
Observed: the red, green, GUI and measurement steps above; the films at 3f079dc47050 and 7dc25a2a2dab and the typing pair at 3f079dc47050. scripts/cmux-next/prepush-gate.py passes (app_lines goes down).
Not run:
- No end-to-end Undo Layout Change against a live daemon: the app test proves one daemon transaction number per press, and the daemon's own test proves one undo entry per number.
- macOS 14 and 15 were not run.
Base and merges: the branch merged feat at dd6fdec92524 (it has #34084) at c4aaf3d0366 (it has #34198, the base red fix for Release builds, and #34199) and at aae372817d1 (the settings-schema autofix ca66a4c92b91); no conflict any time. The PR's own diff is identical line for line across the three merges.
Area owner told: PL-motion-core (LayoutRootView+KeyResize.swift is under the Layout motion row).
Changelog
Changed: Resize Pane Left and Right move the divider beside the pane in the arrow's direction in a strip of columns and at a side dock, as in Ghostty
Proof
No capture: the change is in which divider a key press moves; the refactor owner's 120 Hz key films are the evidence.