fix(app): each held key goes only to the terminal it was typed for (P0 1193, Cmd-D twice)
Summary
P0 1193 (tip watcher smoke on feat 5097a275a876, 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'smacos-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).cmdWTypedForAPendingSplitClosesThatSplitOnlyguards 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).
- Keys of a lane whose terminal is not the focused one are typed by THAT terminal (
- 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 (050651b6e16: 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:
- #29693 at bc84781571b (SHIP): held Control shortcuts, replays and hold ends deliver each key to its own terminal (fixes Follow-ups 1 to 3 below).
- #29919 at ded9410c31e (SHIP): held keys for one terminal go out through one ordered send queue (
DaemonService.sendInOrder). - #29922 at d0ec8faf938 (SHIP): closing a replay-opened split keeps the line carried for another terminal.
Merge of feat 1d10c48b354 (0704e1327a6): 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: 1d942e4397f.
Testing
Red-first: SecondCreationKeysTests (CmuxNextAppTests, real same-tree daemon). The red ref is branch fix/p0-1193-key-lanes-red at 6f698aef842: 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 0383621e0df did not compile: a nested #require and await in #expect messages; fixed.)
keysTypedAfterTheSecondCreationBeganNeverGoToTheFirstTerminal: the smoke's step 23.keysHeldForTheFirstCreationNeverGoToTheSecondTerminal: the other direction (the case the perf rule gets wrong).heldControlAndArrowKeysReachOnlyTheirOwnTerminal: byte readers in both terminals: a held Ctrl-C and Up reach the first terminal as03and cursor-up, nothing reaches the second.eachHeldKeyRunsAtItsPlaceInItsOwnTerminal: Cmd-D, "ls", Up, Ctrl-C, Cmd-D, a line, all held: the first terminal gets6c 73, cursor-up,03in 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.heldKeysReachTheirTerminalAfterItsWorkspaceWasSwitchedAway: Cmd-N before the hold ends; the held line still runs in the first terminal.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.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 201b1e4c804 job ef1700ef9b254b4b1c3be223 PASSED; red ref 6f698aef842 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 6f698aef842 (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 guardcmdWTypedForAPendingSplitClosesThatSplitOnlyPASSES 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 201b1e4c804 (console session,
CMUX_TEST_REQUIRE_GUI=1):SecondCreationKeysTests6 of 6 passed,HeldKeyInputTests3 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 c6358ca7638, 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 ddb6037ce8a (base code + test seams) | 9c32e47752065f44d97a857b | FAIL 5/5 |
| feat 5097a275a876 (the TIP RED tip) + the hook commits, cb31520c89b | 1ac13c76efd73899c7bfee00 | FAIL 5/5 |
| head f260f8e510d (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 1d942e4397f (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.OrderedSendReleaseTests1/1;SecondCreationKeysTests11/11 in each of 3 runs;HeldKeyInputTests3/3;CreationInputHoldTests6/6;SplitOfPendingSplitTests3/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)
- 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:)`). - When a replayed key opens a new creation,
prependgives 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. - 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.