Skip to content

fix(app): each held key goes only to the terminal it was typed for (P0 1193, Cmd-D twice) - #29301

Merged
lawrencecchen merged 46 commits into
feat-cmux-nextfrom
fix/p0-1193-key-lanes
Nov 1, 2026
Merged

lawrencecchen merged 46 commits into
feat-cmux-nextfrom
fix/p0-1193-key-lanes

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

P0 1193 (tip watcher smoke on feat 5097a27, granted mini cmux18, step 23 failed in 2 of 3 rounds): Cmd-D, then a line typed at once, then Cmd-D again, then a line typed at once. Both lines ran in the FIRST new terminal. The second new terminal got its shell but no line and no focus. The pane widths in the round's check-05.png show that the pane with both lines is the first new pane (the second Cmd-D split it).

Cause: CreationInputBuffer.takesKeysEarly(tab:) was true for the early tab of ANY pending creation. After the second Cmd-D, the focus still named the first creation's early view until the second creation's focus landed. The first creation was also still pending, because its reply is slower under load and the second create waits for it in the workspace's create chain. So CreationInputCoordinator.capture let the second line's keys, and the keys held before them, go to the first terminal.

Fix: each held key gets a lane when it is typed. The lane is the creation whose tab the focus named, or was on its way to, at that time (FocusState.typingTab).

  • An early view takes only its own lane's keys, and only when its terminal view is the window's first responder (TerminalSession.hasKeyboard). A key typed for another creation waits for that creation's terminal.
  • At the hold's end, every held key runs in typing order (CreationInputBuffer.HeldKey):
    • Keys of a lane whose terminal is not the focused one are typed by THAT terminal (.type): by its view's own Ghostty key encoder (TerminalSurfaceView.typeHeldKey: modifiers, Option-as-Alt, cursor and keypad modes, Kitty protocol; no input method), also while that view is hidden (another workspace, an unselected tab: the content cache keeps it). When the terminal has no view at all (evicted from the cache, or another machine's), the daemon types them with that terminal's own modes (send-key / send, HeldKeyInput, as a TUI's Command chords already go), on the machine its creation was on; an Option key follows Ghostty's macos-option-as-alt (true, left, right) as the view's encoder does (GhosttyRuntime.optionAsAltValue: ESC and the base key). Only a closed terminal takes its keys with it, as a closed creation already does. A held Ctrl-C never reaches another terminal's process.
    • A Command chord of such a lane (.command) that is an app shortcut runs with that terminal's tab as its target (RegistryKeyBindings.run(_:keyContext:target:)) and with that terminal's key context, as a key press there would; the handlers resolve a tab target to its pane (AppActionContext.paneController, daemonPane). So a held Cmd-D splits the pane it was typed for, never the focused one. A Command chord that is no app shortcut is that terminal's key (a TUI's Cmd keys). An action whose descriptor declares no target kind (New Window, the palette) is app- or window-level and ignores the target.
    • Cmd-W (any close key) is never held: it acts at once on what it closes, and a close of a pending creation drops the keys typed for it (CreationInputBuffer.holds, unchanged). cmdWTypedForAPendingSplitClosesThatSplitOnly guards that rule in the sequence Cmd-D, keys, Cmd-W, Cmd-D.
    • The focused lane's keys and keys of no lane run again as typed, as before (input methods still see them).
  • Nothing is delayed or blocked that was not before, and the smoke gate's typing does not change. Keys are dropped (logged) only for a closed terminal, as before, and in two rare cases noted under Follow-ups.

The perf line's rule (050651b: only the newest pending creation's early tab takes keys) is not ported as is. It sends keys held for the first creation to the second terminal when the second gets the keyboard first.

responderTakesKeys (any non-terminal first responder takes the keys) stays only for the final flush, after every creation replied and the focus it asked for landed. There, the focused view is the one the creation asked for (for example, a New Tab page from a split of a page tab), and only the keys of the focused lane or of no lane go to it. The early path no longer uses it.

Test seam (DEBUG and bench builds only, a release router drops debug.*): debug.creation.hold {n} / debug.creation.release {n} (DebugCreationHold, one per DaemonService, wired in intendNowReporting) hold the n-th creation's daemon reply until released (bound 30 s; the release answers resumed, the creations it really let go). stand_in: [n] also keeps creation n's stand-in out of the store until its release, so its focus is asked for and cannot land: the state of a window whose main thread ran the next keys before it laid the new pane out (TIP RED 1193 under the hog), made on purpose. Without it, the reply hold alone does not fail the base: pa-d's first rounds passed on the red ref, because creation 2's focus landed within a frame, so the smoke journey makes the 1193 race in every round on any host with no hog (cmux-ad 11:25Z). No product timing changes.

CreationHoldLog moves to CmuxNextFocusUI (beside feat's CreationHoldEnd), and DecidedKeyEvents (pure AppKit, the key router's) to CmuxNextFocusUI, so CmuxNextApp shrinks.
Owners told through this PR: PL-typing (TerminalSurfaceView+Keyboard.swift: the new typeHeldKey, no change to keyDown), PL-ghostty (TerminalSession.hasKeyboard).

Combined stack (lead 03:50Z)

The merge service lands only PRs based on feat-cmux-next, so the reviewed follow-ups are merged into this branch with plain merges (no force push). Each part was reviewed at its own head:

Merge of feat 1d10c48 (0704e13): feat's #28810 added the same replay seam as this PR (HeldKeyReplay, injected through AppServices/KeyRouter) and moved CreationHoldEnd to CmuxNextFocusUI. The merge keeps feat's seam (this PR's runKey is gone; the tests pass their replay through PinnedPageTests.withApp(heldKeyReplay:)), keeps one CreationHoldEnd (feat's), and puts CreationHoldLog beside it in CmuxNextFocusUI. Head: 1d942e4.

Testing

Red-first: SecondCreationKeysTests (CmuxNextAppTests, real same-tree daemon). The red ref is branch fix/p0-1193-key-lanes-red at 6f698ae: base sources, plus only two test seams (the replay seam CreationInputCoordinator.runKey and the DEBUG debug.creation.hold hook), plus these tests. The seam is behavior-neutral: its default is { event, _ in NSApp.sendEvent(event) }, the exact call it replaces at the two replay sites, and only a test assigns it (a test window is never key, so NSApp.sendEvent cannot deliver a replayed key there). (The first red commit 0383621 did not compile: a nested #require and await in #expect messages; fixed.)

  1. keysTypedAfterTheSecondCreationBeganNeverGoToTheFirstTerminal: the smoke's step 23.
  2. keysHeldForTheFirstCreationNeverGoToTheSecondTerminal: the other direction (the case the perf rule gets wrong).
  3. heldControlAndArrowKeysReachOnlyTheirOwnTerminal: byte readers in both terminals: a held Ctrl-C and Up reach the first terminal as 03 and cursor-up, nothing reaches the second.
  4. eachHeldKeyRunsAtItsPlaceInItsOwnTerminal: Cmd-D, "ls", Up, Ctrl-C, Cmd-D, a line, all held: the first terminal gets 6c 73, cursor-up, 03 in order; the held Cmd-D splits the FIRST terminal's pane (the new pane lies between the first and the second), at its place; the line after it runs in that new pane; nothing reaches the focused terminal.
  5. heldKeysReachTheirTerminalAfterItsWorkspaceWasSwitchedAway: Cmd-N before the hold ends; the held line still runs in the first terminal.
  6. HeldKeyInputTests: the daemon path's key mapping (option-as-alt per setting and Option side, the config read, the order of text, Up, Ctrl-C, Return). It needs the new API, so it is committed with its fix and has no red run.
  7. cmdWTypedForAPendingSplitClosesThatSplitOnly: Cmd-D, keys, Cmd-W, Cmd-D in one turn: Cmd-W closes only the first split, the original pane stays, the keys reach no terminal. Expected green on the base too (it guards the existing close rule).

Fleet evidence (filled in as the jobs end):

  • Compile: head 201b1e4 job ef1700ef9b254b4b1c3be223 PASSED; red ref 6f698ae job 7c96de5abb1ec85241a977fb PASSED (app, CmuxNext with its tests, cmux-tui). (Earlier: 2c7852b3, a49e899a and 9b64e3fb failed in test or seam files only, fixed.)
  • Red: VALID. Display step 56c5ebbc823d3997348eada3 at the red ref 6f698ae (base code + test seams; gui-session-v1, "gui-session-v1 step in cmux's console session (launchctl asuser 501)", CMUX_TEST_REQUIRE_GUI=1), SecondCreationKeysTests: 5 of 6 fail, each FIRST on its outcome check: "the line typed for the second terminal ran in the first one" (step 23); "the line held for the first terminal ran in the second one"; "a key held for the first terminal reached the second one" (Ctrl-C, Up); "a key held for another terminal reached the focused one" and "the held Cmd-D split another pane than the first terminal's" (x 435 / new 875 / second 655); "the line held for the first terminal was lost when its workspace was switched away". The Cmd-W guard cmdWTypedForAPendingSplitClosesThatSplitOnly PASSES on the base, as a regression guard should. (Earlier runs 5e829f0c5c0ff1f0d9702c6a and f78bf3a645e937c0a98ffb98 failed for test reasons and do not count.)
  • Green: VALID. Display step e080cd4890f129a360c93ac8 at the head 201b1e4 (console session, CMUX_TEST_REQUIRE_GUI=1): SecondCreationKeysTests 6 of 6 passed, HeldKeyInputTests 3 of 3 passed; no skip, no FLAKY notice.
  • On-screen evidence (cmux-ad 11:05Z bar met): pa-d's journey tipred-1193-stand-in (cp/pa-d-dogfood-smoke-gate c6358ca, its own review SHIP at that sha), cmux18 whole host, no memory hog, debug.creation.hold {"n": [1], "stand_in": [2]}. Per round: python3 scripts/journeys/run.py --build JOB --journey tipred-1193-stand-in --exclusive --whole-host --hosts cmux18.
Build Debug job Result
red ref ddb6037 (base code + test seams) 9c32e47752065f44d97a857b FAIL 5/5
feat 5097a27 (the TIP RED tip) + the hook commits, cb31520 1ac13c76efd73899c7bfee00 FAIL 5/5
head f260f8e (the same app code as this PR's head) 741ca0d01ac4bd81d539fb1e PASS 5/5

Every round of all three is the race: the release reply {started: 2, resumed: [1], shown: [2]}, the second Cmd-D "dispatcher: run(splitRight)". Each red: d1 and d2 both in the first new pane's terminal, the focus on the second new pane with no line (TIP RED 1193). Each head pass: each line in its own new pane, hold path true. Data: /work/tmp/pa-d-tipred-1193/v7-standin/{red-,f5097-,head-*}/tipred-1193-stand-in/result.json. Not run: hog rounds on the head.

Observed / Not run

Observed: the smoke round's captures (round-01 and round-02 check-05.png, debug.key replies) and the code paths named above. prepush-gate.py: no new violations.
Combined head 1d942e4 (P0 proof at the exact head):

  • Compile f67ff56f88ef04dff4864286: PASSED (CmuxNext with its tests, app, cmux-tui).
  • Display proof 2c0b0cec11d203e43ecc014e (cmux12s, console session, CMUX_TEST_REQUIRE_GUI=1, same-tree cmux-tui fetched): all 7 suite runs passed, none skipped. OrderedSendReleaseTests 1/1; SecondCreationKeysTests 11/11 in each of 3 runs; HeldKeyInputTests 3/3; CreationInputHoldTests 6/6; SplitOfPendingSplitTests 3/3 (its one known issue is feat's intended check that a replay that drops the keys fails).
  • Not counted: 7b3f3105afedb6195b06f5bf on the same head failed for infrastructure (the worker's same-tree cmux-tui build failed at the ghostty-next fetch, "shallow file has changed"), so its daemon suites were skipped.
    Nothing was built or run locally.

Follow-ups (review receipt, low; fixed by #29693, now merged into this branch)

  1. A held Control chord that is an app shortcut (Ctrl-, Ctrl-Tab, Ctrl-1..9) for a terminal that is not focused is typed into that terminal, not run as the shortcut: only Command chords go through app dispatch (CreationInputBuffer.take(focused:)`).
  2. When a replayed key opens a new creation, prepend gives the remaining keys the new creation's lane; with mixed lanes (a creation started by a click) a key held for another terminal can go to the new split.
  3. A held key's daemon path uses the window's daemon, not the pane's: for an evicted view of another machine's split, the keys are dropped (logged).

Performance

No perf numbers: this is a correctness fix (keys typed while two creations are pending reach the terminal they were typed for). It adds no work on the key path when no creation hold is open (capture returns at its first guard, as before), and it moves no work onto the main thread. perf claim: none.

Changelog

Fixed: Lines and shortcuts typed while new panes open run in the pane they were typed for, in the order they were typed, also when that pane is hidden or closed early

Proof

No capture: key routing between terminals; the evidence is the fleet tests and the smoke journey above.

…each the first terminal, and back (P0 1193)

Tip watcher smoke, feat 5097a27 on cmux18, step 23: Cmd-D, a line at once, Cmd-D, a line at once: both lines
ran in the FIRST new terminal and the second got nothing. SecondCreationKeysTests drives two creation holds on two real
terminals: keys typed after the second creation began must wait for the second terminal; keys held for the first
creation must reach the first terminal, also when the second one has the keyboard first. Red on this base.
…0 1193)

After Cmd-D, a line, Cmd-D, a line typed at once, both lines ran in the first new terminal (tip watcher smoke on
feat 5097a27, 2 of 3 rounds on cmux18). CreationInputBuffer.takesKeysEarly(tab:) was true for the early tab of
ANY pending creation: until the second creation's focus landed, focus still named the first one's early view and the
first creation was still pending (its reply is slower under load, and the second create waits for it), so
CreationInputCoordinator.capture let the second line's keys, and the keys held before them, go to the first terminal.

Each held key now carries a lane: the creation whose tab the focus named, or was on its way to, when it was typed
(FocusState.typingTab). An early view takes only its own lane's keys, and only when its terminal view is the
window's first responder; a key typed for another creation waits for that one. When the hold ends, a lane whose
terminal is not the focused one gets its plain text by that terminal's input (TerminalSession.sendBytes, by the
terminal id its create chose); the focused lane, keys of no lane and chords run again as typed, as before.

The perf line's rule (050651b, "only the newest pending creation's early tab") is not ported as is: it sends
keys held for the first creation to the second terminal when the second gets the keyboard first.

CreationHoldEnd and CreationHoldLog move to CmuxNextFocus (as on the perf line), so CmuxNextApp gains no lines.
Test: SecondCreationKeysTests (red on 0383621).
@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

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

Review 1d942e4: 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=pr29301 -f ref=fix/p0-1193-key-lanes -f platform=macos -f backend_mode=local

red: cmux-next checks (god files, concurrency, crash safety, l10n); no finished base run on 1d10c48 to compare; do not merge until green; see lane-rules

Default changes

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

  • tunable-source.CmuxNextApp/Handlers/PaneHandlers.swift:split: "1b5e985ae601a3fe5f6b4cdb49656b109e88848b008a39a15d18927adde5d4bc" -> "6caa1abde07114b35831d6c3545002389d9fff5995b24a33b9ebee6fff49134e"

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

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

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

Shipped in:

@github-actions github-actions Bot added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 31, 2026
… as 03 and cursor-up (P0 1193)

Review of ad8c348 (coordinator): held keys that are not plain text (Ctrl chords, arrows, Return, Option-as-Alt)
for a terminal that is not focused still ran in the focused pane; a held Ctrl-C there stops another process. Byte
readers in both terminals show which one gets the held Ctrl-C and Up.
… that terminal's own surface (P0 1193)

Review of ad8c348 (coordinator): only plain-text held keys went to their own terminal; Ctrl chords, arrows, Return
and Option-as-Alt keys still ran in the focused pane. Now every held key of a lane whose terminal is not focused goes
to that terminal through TerminalSurfaceView.typeHeldKey: its Ghostty key encoder (modifiers, Option-as-Alt setting,
cursor and keypad modes, Kitty protocol), written to that terminal as a typed key, with no input method. It never
falls back to the focused pane; keys of a terminal whose view is gone are dropped with it (logged), as a closed
creation's are. Command chords are app actions: they run again as typed, in the focus at the hold's end, as before.
Test: SecondCreationKeysTests.heldControlAndArrowKeysReachOnlyTheirOwnTerminal.
…at its place in its own terminal (P0 1193)

cmux-ad 11:15Z review of #29301: a lane holding a non-text key (an arrow, Ctrl-C) or a Command chord went back to
the focused terminal as a whole. The new test needs held keys that run again to be visible in a test window, which is
never key: CreationInputCoordinator.runKey is the seam (NSApplication.sendEvent by default, unchanged).
…y Command chords go through app dispatch (P0 1193)

cmux-ad 11:15Z review: take(focused:writer:) sent a lane with any key that is not plain text back to "the rest", which
ran in the focused terminal, and ran Command chords after every typed key. Now the hold's end is one ordered list
(CreationInputBuffer.HeldKey): a key of a lane whose terminal is not focused is typed by that terminal's own Ghostty
encoder (TerminalSurfaceView.typeHeldKey: arrows, Ctrl chords, Return, Option-as-Alt, keypad and cursor modes); a
Command chord, the focused lane's keys and keys of no lane run again as typed (runKey), each at its typing position. A
Command chord that starts another creation still takes the keys after it into that creation's hold. TerminalSession
.hasKeyboard is the one "this terminal has the keyboard" check.
Test: SecondCreationKeysTests.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal (red at the seam commit).
@cursor

cursor Bot commented Oct 31, 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 added a commit that referenced this pull request Oct 31, 2026
…m (runKey) only, all SecondCreationKeysTests; never merge
…d keys survive a workspace switch; Cmd-W closes only its pending split (P0 1193)

cmux-ad 11:2xZ review of #29301: a held Command chord ran in the focus at the hold's end (a held Cmd-D split the
focused pane, not the first terminal's), and keys of a terminal whose view was gone were dropped. Cmd-W is never held
(a close key acts at once, CreationInputBuffer.holds); its test guards that rule in the reported sequence.
…a hidden or far terminal still gets its held keys (P0 1193)

cmux-ad 11:2xZ review of #29301:
- A held Command chord of a lane whose terminal is not focused ran in the focus at the hold's end. Now an app
  shortcut runs with that terminal's tab as its target (RegistryKeyBindings.run target:) and that terminal's key
  context, as a key press there; handlers resolve a tab target to its pane (AppActionContext.paneController,
  daemonPane). A Command chord that is no app shortcut is that terminal's key (a TUI's Cmd keys).
- Keys of a terminal whose view is gone were dropped. A hidden terminal's view (another workspace, an unselected
  tab) still exists in the content cache and types them; with no view (evicted, another machine's), the daemon types
  them with that terminal's own modes (send-key / send, HeldKeyInput), on the machine its creation was on. Only a
  closed terminal takes its keys with it.
DecidedKeyEvents moves to CmuxNextFocusUI (pure AppKit, the key router's), so CmuxNextApp shrinks.
Tests: SecondCreationKeysTests.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal (split position),
heldKeysReachTheirTerminalAfterItsWorkspaceWasSwitchedAway, cmdWTypedForAPendingSplitClosesThatSplitOnly.
lawrencecchen added a commit that referenced this pull request Oct 31, 2026
… creation's daemon reply until released (P0 1193)

cmux-ad 11:25Z: the 1193 journey controls the race instead of chasing it with load or timestamps. debug.creation.hold
{n} arms the n-th creation (split, New Column, New Row, new terminal tab) that starts after it: the daemon makes the
pane, and the app applies the reply only at debug.creation.release {n} (no n: all, disarmed); a held reply nobody
releases goes on after 30 s. DEBUG and bench builds only (a release router drops debug.* methods); no product timing.
Registered on the existing debug.daemon line, so CmuxNextApp does not grow.
…-as-alt, as its view's encoder does (P0 1193)

cmux-ad 11:3xZ: on the daemon path an Option key sent what macOS composed even when Ghostty's macos-option-as-alt
(true, left, right) makes Option Alt, so another byte reached the terminal than a key press in its view sends.
HeldKeyInput reads the same setting (GhosttyRuntime.optionAsAltValue) and sends alt+<base key> (ESC and the key).
Test: HeldKeyInputTests (the mapping per setting and side, the config read, the order of text, arrows, Ctrl-C and
Return). It needs the new API, so it is committed with the fix; it has no red commit of its own.
lawrencecchen added a commit that referenced this pull request Oct 31, 2026
…d journey (debug.creation.hold / release)

Review CHANGES at 03bb72f (B1): "app" on the second Cmd-D is any router intercept; a chord the router runs at
once (no creation pending, empty buffer) is "app" too. A run passes dispatch, which adds a "dispatcher: <decision>"
trace line; a held key returns from capture before it. held_marks now needs handled_by "app" AND no "dispatcher:"
trace line, and records the trace.
New journey tipred-1193-creation-hold: the smoke gate's steps 1-18, then the P0 owner's DEBUG hook
debug.creation.hold {n: 1} (#29301 head 83508d3 and red ref d12a07d), Cmd-D, d1 at once, Cmd-D, d2 at once,
the hold-path check (before the release), debug.creation.release {n: 1}, and step 23's checks unchanged. Creation 1
stays pending in every round, so the hold path runs every round, without the hog; finally releases every hold.
Tests: test_smoke_gate.py 61 OK (run-at-once "app" with a dispatcher trace -> not proven).
…ne in the control socket's JSON type (P0 1193)

pa-d's Debug builds 8a86e9ae and c1cfa1dc: "the compiler is unable to type-check this expression in reasonable time"
at the reply's one-expression literal, which also named a JSONValue that CmuxNextDaemon defines too. Build it as
debug.daemon does: the call's own params type, one key per line.
…es and a lost send is reported (P0 1193)

Review of #29919 at a51d0a8 (FIX, REVIEW.md rows 9 and 10): sendInOrder's Task captured the DaemonService weakly
and returned nil (success) when it was gone at its turn: the held keys were lost with no report, and the ticket opened
at the call never closed, so an action scope waited for its control timeout. Remote services are released (Cloud, SSH,
Server sessions) and held keys can go to remote terminals. Now the Task keeps the service, as send(_:_:) does:
failure(...) closes the ticket and reports 'not connected' when the connection is gone.
No red test: it needs a DaemonService released while a send waits in its queue, which the app test harness cannot make
deterministically; the change is the same capture send(_:_:) has used.
… ticket (P0 1193)

The same test as the red ref fix/p0-1193-followup-ordered-release-red (25a36d3, a51d0a8 + the test only), on the head
that already has the fix (a6f62ea, pushed before the test). Red on 25a36d3.
…es-merge2

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift
…ds' into p0-1193-combined

# Conflicts:
#	Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift
@lawrencecchen lawrencecchen removed 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 1, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent): SHIP at 1d942e4
reviewer-session: 29374706-521f-422b-97c6-4a8f53a6c0c7
author-session: a728800f37f9342b9
R9: pass
perf claim: none

Verdict: SHIP (merge resolutions correct; the fleet compile and display proof were not finished when I reviewed)

#29301 now combines the reviewed stack into one PR on feat-cmux-next with three plain merges. Each part had a SHIP receipt:

I reviewed the full diff git diff origin/feat-cmux-next...1d942e4397f (20 files, +1245/-72). I also reviewed each merge with git show --remerge-diff, which shows only the conflict resolutions and the changes a clean merge did not make. The merge base is 1d10c48. Between that base and the feat-cmux-next tip (d0fce7f), nothing changed in CmuxNextFocus, CmuxNextFocusUI or DecidedKeyEvents.

Merge 0704e13 (feat 1d10c48 into 33cee3e)

  • Replay seam: feat test(split): #28436 was the test window, not the key path: type after the hold, and cover typing during it #28810 already added replay, so the resolution keeps it and removes the PR's runKey. .run now calls if let router { replay(event, window, router) }. With no test override, the app still replays through NSApp.sendEvent:

    • AppDelegate.swift:127 passes heldKeyReplay: { event, _, _ in NSApp.sendEvent(event) }.
    • The KeyRouter.init default is the same.
    • KeyRouter.creationInputCoordinator gives it to CreationInputCoordinator(router:replay:).
    • The PR's old runKey default was also NSApp.sendEvent, so the app does not change.
  • No duplicate type: CreationHoldEnd is now only in CmuxNextFocusUI/CreationHoldEnd.swift (feat's copy). The deleted CmuxNextFocus copy had the same five cases, including creationFailed. CreationHoldLog moved to CmuxNextFocusUI, next to the enum. It needs no other module (CmuxNextFocusUI depends on Actions, Design and Signposts). No other source or test refers to the old location or to the removed lastHoldEnd/failedCreation stored properties. Tests use lastHoldEnd(in:), which stays.

  • Feat behavior kept: feat's failed-creation tracking (failedCreation, .creationFailed on an unforced end, .windowClosed when the window is gone) is now in CreationHoldLog, with the same logic:

    • resolve(landed: false) calls creationFailed(in:).
    • The window-gone path calls windowClosed.
    • end(in:forced:) = forced ?? (failed ? .creationFailed : .focusLanded).

    Feat's "a replayed key opened a new hold: the rest wait for it, in order" behavior is in runAgain: next !== buffer || openedCount changed leads to prepend(..., issuer:). In flush, the old buffer has already been removed, so any new buffer matches the next !== buffer check.

  • PR behavior kept: lanes, .type/.command, the early-lane path, typingTab and machine: on begin are all present in the resolved coordinator.

  • Imports: the coordinator imports CmuxNextFocus (for FocusState.typingTab) and CmuxNextFocusUI. KeyRouter already imports both.

  • SecondCreationKeysTests: it passes its window replay through PinnedPageTests.withApp(heldKeyReplay:) instead of setting runKey. The replay is the same as before (key router, then window.sendEvent).

Feat tests that use the seam (SplitOfPendingSplitTests)

I checked that the PR's lane routing does not change what these tests prove:

  • In the "typed during the hold" test, the keys are typed after the second Cmd-D. At that time the focus expectation names the second split's tab, so they get that split's lane.
  • At the hold's end the focus is on that tab, so they are .run keys, and they go through replay.
  • The split's public tab id is minted by the client, and the daemon's pane keeps it (ProvisionalPane.tabID). Thus the lane tab and the focused tab match after the split is confirmed.
  • So the positive test still runs the line in the second pane. The "drop" test (replay: { _, _, _ in }) still fails on its own assertion inside withKnownIssue.
  • The display proof 7b3f3105afedb6195b06f5bf includes SplitOfPendingSplitTests, so it will confirm this.

Merge b22ba9f (#29919 ded9410)

One conflict, resolved as case .run(let event, _): if let router { replay(event, window, router) }. This combines #29919's HeldKey.run(_, lane:) shape with feat's seam. All other #29919 content merged without conflict: sendInOrder, the strong capture, and OrderedSendReleaseTests.

Merge 1d942e4 (#29922 d0ec8fa)

No conflict, and --remerge-diff shows nothing. The change in CreationInputBuffer.cancel and its test closingAReplayOpenedSplitKeepsTheLineCarriedForAnotherTerminal are present.

Evidence state at review time (read-only cmux-ci status)

  • Compile f67ff56f88ef04dff4864286 (cmux-next-swift-check ... --package CmuxNext --app at 1d942e4): running.
  • Display proof 7b3f3105afedb6195b06f5bf (OrderedSendReleaseTests, SecondCreationKeysTests x3, HeldKeyInputTests, CreationInputHoldTests, SplitOfPendingSplitTests at 1d942e4): queued.
  • This SHIP covers source correctness. The author must see both jobs green at this exact head before ready-to-land. A compile error or a red in these suites cancels this receipt.

Not blocking (carried from the earlier reviews)

Other writers to the same terminal are still unordered against sendInOrder:

  • the view's TerminalAttachment path,
  • TerminalSession.sendBytes,
  • the TerminalHooks send-key.

They were unordered before this PR too, and they need a live view. I recommend a follow-up.

Not run by me: no build or test run.

@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 1, 2026
@lawrencecchen
lawrencecchen merged commit 373f346 into feat-cmux-next Nov 1, 2026
223 of 242 checks passed
@lawrencecchen
lawrencecchen deleted the fix/p0-1193-key-lanes branch November 1, 2026 04:58
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…cus; #29301 moved them to CmuxNextFocusUI

The merge of feat (with #29301 at 1d942e4) kept both copies: this branch's base (#29301
at 201b1e4) had them in CmuxNextFocus, #29301's final head moved them to CmuxNextFocusUI.
Compile f24aceec at 748ab11: 'CreationHoldEnd' is ambiguous. The FocusUI copies are the
live ones; no other code change.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
Conflicts with #29301 (373f346, held keys carry a lane: the creation whose
tab the focus named):
- CreationInputBuffer: open takes both `machine` (feat) and `newWorkspace`
  (this PR). A key whose focus names no creation's tab while a new workspace
  of the hold is on its way goes to that workspace's lane (Home showed, so
  the focus named no new tab). take(shown:) becomes dropKeys(ofWorkspacesOtherThan:)
  before feat's take(focused:isShortcut:); take(shown:) stays as drop + take.
- CreationInputCoordinator: feat's CreationHoldLog (holdEnds) replaces the
  local failedCreation/lastHoldEnd; the flush drops unshown workspace keys,
  then takes the lanes as feat does. One comment line folded to keep the file
  at the 400-line limit.
- DecidedKeyEvents: feat made the same move to CmuxNextFocusUI; feat's copy.
- CreationInputHoldTests: both tests kept; this PR's captures name no
  focused tab.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
A move only. KeyWindowRole says which window the key window acts for and its
close rule (keyboard focus code; it reads WindowKindRegistry and
isChromiumPageWindow, both already in CmuxNextDesign and CmuxNextFocusUI).
The only edits are the access words a move across modules needs (public on
the type, its fields and resolve; public imports) and dropping the import of
its new module. KindlessWindowAudit stays internal beside it. Feat moved
DecidedKeyEvents itself in #29301, so this PR needs one more move to keep
CmuxNextApp from growing (app-size rule).
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…x/composer-claim-fail

The base merged feat-cmux-next: CreationHoldLog (holdEnds) replaces the
coordinator's failedCreation, and the flush drops unshown workspace keys
before feat's per-lane take. This branch's failed-create path is kept:
endFailed ends the claim, the dropped count comes back from the buffer, and
the window's focus applies again when a hold ends as creationFailed. Five
lines folded (two doc comments, two attributes inline, one comment) to keep
CreationInputCoordinator.swift at the 400-line limit.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
… applies the window's focus (red)

Review of 93a487c (FIX, #30103 (comment)):
the merge with #29301's CreationHoldLog re-applies the focus only when the
hold's end reads creationFailed, and a forced end (limitReached, focusLeft)
does not say a creation failed. The test fails a claimed create while
another creation of the hold waits for a focus that never lands, so the hold
ends at its limit, and expects the window's focus to apply again
(contentPresented in the focus coordinator's recent events).
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…ocus, never into the closing terminal (P0 close-then-type) (#29409)

* test(app): keys typed for the second of two pending creations never reach the first terminal, and back (P0 1193)

Tip watcher smoke, feat 5097a27 on cmux18, step 23: Cmd-D, a line at once, Cmd-D, a line at once: both lines
ran in the FIRST new terminal and the second got nothing. SecondCreationKeysTests drives two creation holds on two real
terminals: keys typed after the second creation began must wait for the second terminal; keys held for the first
creation must reach the first terminal, also when the second one has the keyboard first. Red on this base.

* fix(app): each held key goes only to the terminal it was typed for (P0 1193)

After Cmd-D, a line, Cmd-D, a line typed at once, both lines ran in the first new terminal (tip watcher smoke on
feat 5097a27, 2 of 3 rounds on cmux18). CreationInputBuffer.takesKeysEarly(tab:) was true for the early tab of
ANY pending creation: until the second creation's focus landed, focus still named the first one's early view and the
first creation was still pending (its reply is slower under load, and the second create waits for it), so
CreationInputCoordinator.capture let the second line's keys, and the keys held before them, go to the first terminal.

Each held key now carries a lane: the creation whose tab the focus named, or was on its way to, when it was typed
(FocusState.typingTab). An early view takes only its own lane's keys, and only when its terminal view is the
window's first responder; a key typed for another creation waits for that one. When the hold ends, a lane whose
terminal is not the focused one gets its plain text by that terminal's input (TerminalSession.sendBytes, by the
terminal id its create chose); the focused lane, keys of no lane and chords run again as typed, as before.

The perf line's rule (050651b, "only the newest pending creation's early tab") is not ported as is: it sends
keys held for the first creation to the second terminal when the second gets the keyboard first.

CreationHoldEnd and CreationHoldLog move to CmuxNextFocus (as on the perf line), so CmuxNextApp gains no lines.
Test: SecondCreationKeysTests (red on 0383621).

* test(app): a held Ctrl-C and arrow key reach only their own terminal, as 03 and cursor-up (P0 1193)

Review of ad8c348 (coordinator): held keys that are not plain text (Ctrl chords, arrows, Return, Option-as-Alt)
for a terminal that is not focused still ran in the focused pane; a held Ctrl-C there stops another process. Byte
readers in both terminals show which one gets the held Ctrl-C and Up.

* fix(app): a held key for a terminal that is not focused is encoded by that terminal's own surface (P0 1193)

Review of ad8c348 (coordinator): only plain-text held keys went to their own terminal; Ctrl chords, arrows, Return
and Option-as-Alt keys still ran in the focused pane. Now every held key of a lane whose terminal is not focused goes
to that terminal through TerminalSurfaceView.typeHeldKey: its Ghostty key encoder (modifiers, Option-as-Alt setting,
cursor and keypad modes, Kitty protocol), written to that terminal as a typed key, with no input method. It never
falls back to the focused pane; keys of a terminal whose view is gone are dropped with it (logged), as a closed
creation's are. Command chords are app actions: they run again as typed, in the focus at the hold's end, as before.
Test: SecondCreationKeysTests.heldControlAndArrowKeysReachOnlyTheirOwnTerminal.

* test(app): Cmd-D, ls, Up, Ctrl-C, Cmd-D, a line, held: each key runs at its place in its own terminal (P0 1193)

cmux-ad 11:15Z review of #29301: a lane holding a non-text key (an arrow, Ctrl-C) or a Command chord went back to
the focused terminal as a whole. The new test needs held keys that run again to be visible in a test window, which is
never key: CreationInputCoordinator.runKey is the seam (NSApplication.sendEvent by default, unchanged).

* fix(app): every held key runs at its place, for its own terminal; only Command chords go through app dispatch (P0 1193)

cmux-ad 11:15Z review: take(focused:writer:) sent a lane with any key that is not plain text back to "the rest", which
ran in the focused terminal, and ran Command chords after every typed key. Now the hold's end is one ordered list
(CreationInputBuffer.HeldKey): a key of a lane whose terminal is not focused is typed by that terminal's own Ghostty
encoder (TerminalSurfaceView.typeHeldKey: arrows, Ctrl chords, Return, Option-as-Alt, keypad and cursor modes); a
Command chord, the focused lane's keys and keys of no lane run again as typed (runKey), each at its typing position. A
Command chord that starts another creation still takes the keys after it into that creation's hold. TerminalSession
.hasKeyboard is the one "this terminal has the keyboard" check.
Test: SecondCreationKeysTests.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal (red at the seam commit).

* test(app): SecondCreationKeysTests: no #require nested in #require (compile 2c7852b3)

* test(app): a held app shortcut acts on the pane it was typed for; held keys survive a workspace switch; Cmd-W closes only its pending split (P0 1193)

cmux-ad 11:2xZ review of #29301: a held Command chord ran in the focus at the hold's end (a held Cmd-D split the
focused pane, not the first terminal's), and keys of a terminal whose view was gone were dropped. Cmd-W is never held
(a close key acts at once, CreationInputBuffer.holds); its test guards that rule in the reported sequence.

* fix(app): a held app shortcut runs on the terminal it was typed for; a hidden or far terminal still gets its held keys (P0 1193)

cmux-ad 11:2xZ review of #29301:
- A held Command chord of a lane whose terminal is not focused ran in the focus at the hold's end. Now an app
  shortcut runs with that terminal's tab as its target (RegistryKeyBindings.run target:) and that terminal's key
  context, as a key press there; handlers resolve a tab target to its pane (AppActionContext.paneController,
  daemonPane). A Command chord that is no app shortcut is that terminal's key (a TUI's Cmd keys).
- Keys of a terminal whose view is gone were dropped. A hidden terminal's view (another workspace, an unselected
  tab) still exists in the content cache and types them; with no view (evicted, another machine's), the daemon types
  them with that terminal's own modes (send-key / send, HeldKeyInput), on the machine its creation was on. Only a
  closed terminal takes its keys with it.
DecidedKeyEvents moves to CmuxNextFocusUI (pure AppKit, the key router's), so CmuxNextApp shrinks.
Tests: SecondCreationKeysTests.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal (split position),
heldKeysReachTheirTerminalAfterItsWorkspaceWasSwitchedAway, cmdWTypedForAPendingSplitClosesThatSplitOnly.

* test seam(debug): debug.creation.hold / debug.creation.release hold a creation's daemon reply until released (P0 1193)

cmux-ad 11:25Z: the 1193 journey controls the race instead of chasing it with load or timestamps. debug.creation.hold
{n} arms the n-th creation (split, New Column, New Row, new terminal tab) that starts after it: the daemon makes the
pane, and the app applies the reply only at debug.creation.release {n} (no n: all, disarmed); a held reply nobody
releases goes on after 30 s. DEBUG and bench builds only (a release router drops debug.* methods); no product timing.
Registered on the existing debug.daemon line, so CmuxNextApp does not grow.

* fix(app): a key held for a terminal with no view honours macos-option-as-alt, as its view's encoder does (P0 1193)

cmux-ad 11:3xZ: on the daemon path an Option key sent what macOS composed even when Ghostty's macos-option-as-alt
(true, left, right) makes Option Alt, so another byte reached the terminal than a key press in its view sends.
HeldKeyInput reads the same setting (GhosttyRuntime.optionAsAltValue) and sends alt+<base key> (ESC and the key).
Test: HeldKeyInputTests (the mapping per setting and side, the config read, the order of text, arrows, Ctrl-C and
Return). It needs the new API, so it is committed with the fix; it has no red commit of its own.

* test seam(debug): debug.creation.hold builds its reply one key per line in the control socket's JSON type (P0 1193)

pa-d's Debug builds 8a86e9ae and c1cfa1dc: "the compiler is unable to type-check this expression in reasonable time"
at the reply's one-expression literal, which also named a JSONValue that CmuxNextDaemon defines too. Build it as
debug.daemon does: the call's own params type, one key per line.

* test seam(debug): debug.creation.release answers "resumed", the creations whose waiting reply it let go (P0 1193)

pa-d's journey review: "released" lists every number a release named, so it cannot prove that creation 1 waited.
"resumed" lists only the creations this call found waiting and resumed.

* test seam(debug): debug.creation.hold {stand_in: [n]} keeps creation n's stand-in out of the store until its release (P0 1193)

pa-d's rounds (cmux18, no hog): with only creation 1's reply held, the red ref PASSED: creation 2's stand-in landed
the focus within a frame, before d2's first key. The 1193 state needs creation 2's focus asked for (in its key's turn)
and not landed while d2 is typed, as when the main thread ran the keys before it laid the new pane out (the hog).
stand_in makes that state on purpose: the intent waits, the reply waits too, and the release shows the stand-in first
("shown" in its reply). DEBUG and bench builds only.

* test(app): SecondCreationKeysTests make both terminals by splits, and every wait says what it waited for and what it saw (P0 1193)

Red step 5e829f0c5c0ff1f0d9702c6a (red ref 25b488f) was no valid red: 5 tests stopped in setup at line 50
("timed out: panes [3: conversation 2, 6: pty 5]"): the test app's first pane is an agent chat, not a terminal, so
the wait for two terminal panes never held. The Cmd-W guard read the new pane's currentTabKey in the split's own turn
(nil: the pane shows its stand-in in a later frame). Now two splits make both terminals, each focused with the keyboard
before the test body; the Cmd-W test starts from a terminal and reads the stand-in's tab from its model.

* test(app): SecondCreationKeysTests red on the outcome the person sees, not on the hold mechanism (P0 1193)

Red step f78bf3a645e937c0a98ffb98 (red ref 656454a), coordinator's reading:
- Four tests failed on "were the keys held": the setup took the keyboard away (makeFirstResponder(nil)) and opened an
  early tab, but the app's focus gives the terminal the keyboard back, so on the base the keys went straight to it.
  Now lane 1's creation has no early view and the keyboard stays: the keys are held on any build, and the builds
  differ only in where the hold delivers them. Every test checks first which terminal got which line, then how.
- wait-for's timeout threw (line 141); a timeout is now the answer "the line did not run there".
- The Cmd-W guard required a pane from the second Cmd-D; on the base that Cmd-D made none (the focus stayed on the
  closed stand-in, a defect of its own, reported apart). The guard now checks only what it guards: the closed split
  is gone, the pane it was typed from stays, and no key reached another terminal.

* test(app): a line typed right after Cmd-W runs whole in the pane that gets the focus (P0 close-then-type, red)

Journey r3-after-switch round s3 (cmux19, build a0f707a2, STATUS 1333/1353): Cmd-W on the
focused right terminal, then a line typed at once; the left shell ran 'cho ...'. A person's
Cmd-W on a live terminal first asks the daemon what it runs (TabCloseGuard), and until that
read answers the closing terminal keeps the keyboard, so keys typed then go into it.

CloseThenTypeKeysTests drives the real key path (key router, then the window's first
responder) against a same-tree daemon:
- aLineTypedRightAfterCmdWRunsWholeInThePaneThatGetsTheFocus: red on feat (the line runs in
  the closing terminal).
- keysTypedWhileTheCloseWarningIsCancelledGoToTheTerminalThatStays: the cancel case, a guard.

CreationInputCoordinator.runKey is a behavior-neutral seam (NSApp.sendEvent by default, the
same line as #29301), so a held key that runs again reaches a test window that is never key.

* refactor(focus): ShortcutHintModifierPolicy moves to CmuxNextFocusUI (no behavior change)

The intentional-hold rule of the shortcut hints has no App dependency (AppKit only): it moves
from the App to the focus UI package, public, with the same code. Lowers the App's lines
(arch rule: the App is a thin shell); it pays for the close-then-type fix's App lines.

* refactor(focus): PaneNavigationHintPlan moves to CmuxNextFocusUI (no behavior change)

The pane-navigation hint plan is pure (no AppKit, no registry; ActionID only): it moves from
the App to the focus UI package, public with an explicit init, the same code. Lowers the App's
lines (arch rule: the App is a thin shell); it pays for the close-then-type fix's App lines.

* fix(app): keys typed right after Cmd-W go to the pane that gets the focus, never into the closing terminal (P0 close-then-type)

A person's Cmd-W on a live terminal waits for the daemon's read first (TabCloseGuard, the
close warning; CloseUndoToasts.decideFirst when it asks the terminals itself), and the closing
terminal kept the keyboard meanwhile: keys typed then went into it and closed with it (journey
r3-after-switch s3: the left shell ran 'cho ...').

Now the read opens a close lane in the window's creation hold (CreationInputBuffer.openClose):
keys typed while the focus names the closing tab wait, in order, as that close's keys.
- The close ran: they go to the terminal focused after it (bound when the close ends, so no
  guess of the focus reducer's pick), through #29301's lanes.
- The warning asks: they leave the hold (the question takes its own keys) and wait for the
  answer. Close: as above. Cancel: they go to the terminal that stays, by that terminal (its
  view's encoder, else the daemon), wherever the focus is then; no timeout can drop them.
Never dropped, never into the closing terminal; nothing waits on the main thread.

Test added: keysOfACancelledCloseGoToTheTerminalThatStaysWhenTheFocusMovedAway (the person
clicks into the left pane while the warning shows, then cancels).

* test seam(debug): debug.close.hold / debug.close.release hold the close warning's daemon read (P0 close-then-type)

DEBUG and bench builds only (a release router drops every debug.* method; closeReadStarts is
a no-op there): a test arms the hold, presses Cmd-W, types, and releases, so the keys are typed
inside the close warning's read in EVERY round, on any host (coordinator 11:5xZ; the race
needs a key inside a read of a few ms). An armed hold nobody releases ends after 30 s.
debug.close.release answers 'resumed', the reads that really waited for it.

* test(app): close-then-type tests make both terminals as new panes; each wait says what it waits for

Red step d10aec612b19 failed in the set-up on feat: the window's first pane shows an agent chat
('conversation'), not a terminal, so the wait for two terminal panes never held. Both
terminals are now new panes (Cmd-D, then Cmd-D from the first new terminal), and every wait's
failure names what it waited for and the state it saw.

* test(app): the close-then-type test holds the close read and asserts where the line went

Red step a07f5c2a: the close test failed on a wait-for timeout (a thrown error), not on the
bug's visible outcome. Now the test holds the close warning's read with the debug seam
(DebugCloseReadHold.arm, the same seam the journey uses), types the line inside it, reads where
the keys went, then releases (resumed must be 1). It fails with the outcome: 'the line ran in
the closing pane', 'first key lost', or 'the line did not run whole in the left pane (<where it
went>)'. A wait no longer throws (false on a timeout). The cancel test with the focus moved away
(keysOfACancelledCloseGoToTheTerminalThatStaysWhenTheFocusMovedAway) is on the red ref too.

* test(app): "first key lost" looks for the shell running "cho ...", not the substring

"echo cw..." contains "cho cw" too, so the check fired on a correct run (green cb81d130 at
8bbdf97: the left pane showed "echo cw547478x$((6*7))" and "cw547478x42", the line ran
whole). It now looks for the prompt line "$ cho cw..." or zsh/sh "command not found: cho".

* fix(app): a close hold arms only for a live terminal, never for a cancelled creation's stand-in (STATUS 1644)

Cmd-D, then Cmd-W at once cancels the split's creation (TabLifecycle), and the close list can
still name its stand-in (the Cmd-D-after-Cmd-W P0 owner removes it there). A stand-in has a
provisional surface and no daemon read to wait for, so CreationInputCoordinator.beginClose now
takes the closing TabModels and arms only for a live pty (not dead, not provisional): the next
keys never wait for a pane that is already gone.

Test: aCancelledStandInArmsNoCloseHoldAndTheNextLineRunsInTheLiveTerminal (the guard returns no
ticket for the stand-in; after Cmd-D + Cmd-W no hold is left, and a line typed then runs whole
in the focused live terminal; no hold ran out its 2 s bound).

* refactor(focus): TypeAheadQueue moves to CmuxNextFocus (no behavior change)

The type-ahead queue mirrors cmux-focus-core's TypeAheadQueue and calls it through
FocusReducer, which already lives in CmuxNextFocus; it has no AppKit or App dependency. It
moves there, public with an explicit init, the same code. Lowers the App's lines (arch rule:
the App is a thin shell); it pays for the close-then-type review fix's App lines.

* fix(app): a second quick Cmd-W during the close read keeps the keys held; chained closes, a gone window and Cancel's machine (review of #29409)

Review FIX at 3da8089 (#29409 (comment)):
1. A second Cmd-W while the first close still read flushed the hold, and the held keys (then
   the next ones) ran into the closing terminal. Now a close key while a close of the focused
   terminal reads acts at once and the keys keep waiting; a close that starts then opens its
   own close lane; a close that ends while another still waits gives its keys to that one, in
   order; a close that ends while another close's question is up waits for that answer.
2. A window gone during the read left its close ticket pending (no limit): the ticket now
   settles and resolves without a controller.
3. New test for the warning's Close path.
4. TabModel.isLiveTerminal no longer splits hasUnread from its doc comment.
5. Cancel delivers to the closing tab's machine (kept from the close's start), not the
   focused workspace's.

Tests: aSecondCmdWDuringTheCloseReadKeepsTheKeysOutOfTheClosingTerminal,
keysTypedBeforeTheCloseWarningGoToThePaneThatGetsTheFocusOnClose (both on the red ref first).

* test(app): the warning-Close test waits until the close read is held before it releases

Steps 08e73ca1 (red) and 258f0a0c (green) both failed this test on its own set-up check
("resumed 0"): the close warning's read starts in a later main-actor turn, and the test released
the hold in the same turn it typed, before any read waited. DebugCloseReadHold.held now says
how many reads wait, and the test waits for 1 before it releases. Test and DEBUG seam only.

* fix(merge): drop CreationHoldEnd/CreationHoldLog copies in CmuxNextFocus; #29301 moved them to CmuxNextFocusUI

The merge of feat (with #29301 at 1d942e4) kept both copies: this branch's base (#29301
at 201b1e4) had them in CmuxNextFocus, #29301's final head moved them to CmuxNextFocusUI.
Compile f24aceec at 748ab11: 'CreationHoldEnd' is ambiguous. The FocusUI copies are the
live ones; no other code change.

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 1d942e43 Deployed Nov 1, 2026 by lawrencecchen via wait for same-key tree publisher #93481
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