fix(focus): a sidebar responder the platform picks never takes a pane's keyboard (QUIT P0 second fault, Rust part)
Summary
QUIT P0, second fault: after Cmd-Q (Keep Sessions) and reopen, the first line the person types does not reach the restored terminal. It goes to the sidebar list.
Cause (diagnosis rounds quitp0-keyloss-diag, 3 of 4 runs failed with first_responder = SidebarListView): at launch, the restored pane's content is not presented yet, so AppKit makes the sidebar list the window's first key view. The app reports that responder to the focus reducer as Source::Programmatic. The reducer took it as a choice of the person and moved the focus target from the pane to the sidebar. The keys then went to the list.
Fix (cmux-focus-core accepted()): a sidebar-list responder that the platform picks on its own (programmatic source) never takes a pane's keyboard while the state has a live pane: the reducer re-applies the pane focus. A click or a key on the sidebar (the person) and a FocusTarget intent still move focus to the sidebar. The list's echo of a sidebar focus that the state already has returns Same, so a keyboard sidebar focus stays a keyboard focus.
This is PR 1 of 2. The app links the pinned CCmuxAppFFI release, so this PR changes only Rust. After it lands on feat-cmux-next, app-ffi-release.yml publishes a new FFI release (push trigger on cmux-focus-core). PR 2 bumps the pin and adds the Swift side: RestoredPaneKeepsKeyboardTests (5 tests, one on a real display), the focus checker invariant T4 (no input event and no cmux source must not move the target off the focused pane), and the Focus Sidebar order. PR 2 closes the P0.
Red and green
Command: cargo test --test focus_rules in cmux-tui/crates/cmux-layout-reducer-ffi (its own Cargo workspace; it runs in the lint (linux) job of cmux-tui.yml), dispatched for the exact branch tip.
- Red, test commit f9252cd23a8 (scratch ref
fix/p0-quit-reopen-focus-rule-red): run 41279229704. Expected: 2 of the 3 new tests fail on outcome (a_sidebar_responder_the_platform_picks_never_takes_a_panes_keyboard,the_lists_echo_keeps_a_keyboard_sidebar_focus);a_person_still_puts_the_keyboard_on_the_sidebaris a guard and passes on the base too. Result (lint (linux)job 124773708985): FAILED, 6 passed, 2 failed, as expected:a_sidebar_responder_the_platform_picks_never_takes_a_panes_keyboard("the platform's own pick moved focus to the sidebar": targetsidebar{keyboard:false}, wantedcontent) andthe_lists_echo_keeps_a_keyboard_sidebar_focus("the echo dropped the keyboard flag":keyboard:false, wantedtrue). The guard test and the corpus replay passed. - Fix without the fixture, b0f396c9e50 (scratch ref
fix/p0-quit-reopen-focus-rule-nofixture): run 41279237464. Expected: the new tests pass and the corpus replay fails. The replay stops at its first mismatch, so this run shows only the first one (seed 63 step 23). That only these three steps differ (seed 111 step 7, seed 63 step 23, seed 104 step 10) comes from a JSON comparison of the corpus before and after a592541c1b4; the reviewer repeated it. Result (lint (linux)job 124773709520): FAILED, 7 passed, 1 failed: the 3 new tests pass, andthe_rust_focus_rules_reproduce_every_recorded_swift_stepfails at "seed 63 step 23: state after {responder: sidebar, source: programmatic}". - Green, head a592541c1b4: run 41274981606,
lint (linux)passed,focus_rules8 passed.
In these dispatch runs, test (linux) fails with "test_filter 'focus_rules' selected no Rust tests": the dispatch filter only looks in the main cmux-tui workspace, and focus_rules lives in the separate reducer-ffi workspace. That is a property of the dispatch, not of this change; the lint (linux) job is the one that runs these tests.
Fixture change (reviewer: please check)
Commit a592541c1b4 changes three recorded steps in tests/focus/corpus.json. The old steps encoded the bug: in each, a programmatic sidebar responder took a pane's keyboard (seed 111 step 7, seed 104 step 10), or the list's echo turned a keyboard sidebar focus into a non-keyboard one (seed 63 step 23). The Swift recorder that wrote the corpus no longer exists (the rules live only in cmux-focus-core), so the corpus cannot be re-recorded from a real run. Each changed step is the new rule's output. The fixture is in its own commit, and the run at b0f396c9e50 shows the replay fails at exactly these three steps without it.
Follow-ups for PR 2 (from the review)
- Focus Sidebar run outside an input event (
action.runfrom the socket) callsfocusList()before it sends its target; the new rule re-applies the pane, and the later sidebar target does not move the keyboard to the list. PR 2 sends the target first (already on its branch) and will make the focus-effect step move the keyboard to the list, so the order does not matter. !state.target.is_sidebar()also re-applies text-field and empty-pane targets;is_pane_scoped()matches the comment more exactly. PR 2 decides with its display test.- VoiceOver moving to the list arrives as programmatic: check on the display worker in PR 2.
Observed / Not run
- Observed: the diagnosis rounds on a granted mini (first responder
SidebarListViewafter reopen, 3 of 4); the green run above. - Not run on this PR: the app-level display test. It needs the new FFI release, so it runs in PR 2 on a display worker (its red, display step da75a471, failed as a red should with the old rule).
risk:normal: focus rule logic, no data, no lifecycle.
No capture: this PR changes only the Rust focus reducer; the app links it in PR 2 (FFI pin bump), which carries the display test.
Changelog
none (the app gets this rule through the FFI pin bump in PR 2, which carries the user-visible line)