agent cursor visibility: a typed C ABI call for the per-frame path (B9b step A)

Merged
LA
4d ago
+1224-44

Step A of a two-step FFI move (backlog B9 follow-up). The agent cursor visibility rules get a typed C ABI call, cmux_agent_cursor_visibility, for the app's per-frame path. It has no JSON encode or decode and no allocation per call. There's no Swift change and no behavior change: the app keeps calling the JSON operation until step B. Step B moves the Swift resolver to this call and removes the JSON path.

Check the app FFI pin: expected, step A of a two-step FFI move.

Why

#32028 moved the rules to Rust behind the JSON operation agent_cursor_visibility. That call runs on a per-frame path while an agent cursor target is tracked:

  • Once per agent input event: AgentCursorOverlayModel.draw, then AgentCursorVisibilitySource.resolve. Streamed agent pointer moves come as often as one every 10 ms.
  • Once per frame of pane motion: ScreenContentView+Motion.swift:255 asks for an overlay sync on every changed applyPresentation frame (perf-hot, 4 ms motion-frame). LayoutRootView.syncOverlay then calls notifyOverlaySync, and AgentCursorVisibilitySource.reresolve resolves every tracked target.

The JSON path costs 18083 ns p50, 20583 ns p95, 25709 ns p99 and 47209 ns max per resolve. That's AgentCursorVisibilityResolveBench, release build, cmux18-perf, job 81c8069152ac357eeb4c478d. The repo rule for a per-frame path is a typed C ABI, not JSON.

What changes

  • cmux-focus-core/src/agent_cursor_visibility.rs: the rules run once, in resolve_view, over a SnapshotView trait. The JSON operation's resolve reads a Snapshot with string ids through it. The typed call reads the caller's numbered u32 ids and flat arrays through it. The rules compare ids only for identity, so both paths give the same answer. Rect is #[repr(C)], so it crosses the boundary unchanged.
  • cmux-layout-reducer-ffi/src/agent_cursor.rs and the header: CmuxCursorSnapshot (target, tab location, windows front to back, with listed workspaces, sidebar rows, panes and chips as ranges into flat arrays) in, CmuxCursorVisibility (kind, window, anchor, reason, rects, zoom, scale) out. Every range is checked before the rules read it. A null array with a nonzero count is ERR_NULL, a range outside its array is ERR_INVALID, and a panic is ERR_PANIC. out is written only on OK. This follows the pattern of cmux_close_focus_* and cmux_focus_*.

Tests

cmux-layout-reducer-ffi/tests/agent_cursor_typed.rs:

  • Every vector of schemas/agent-cursor-visibility/vectors.json, numbered the way the app numbers it, answers its expect through the typed call.
  • The typed call and the JSON rules agree on every vector, and on each vector with each window removed, each pane's chips cleared, and each pane's page cleared.
  • The structs have the header's sizes.
  • Null and out-of-range input is refused, and a refused call leaves out unchanged.

The existing cmux-focus-core and C ABI JSON tests still cover the JSON operation.

Changelog

none

Observed / Not run

Observed. cmux-layout-reducer-ffi is its own Cargo workspace, so the fleet's Rust step doesn't build it. The proofs run on never-merge refs that add it to the cmux-tui workspace (listed in stopped.txt). Their FFI and focus-core trees equal this PR's, checked with git rev-parse <sha>:<dir>.

  • Red at 4924ad28f5a (349252c34b6 plus the workspace commit: header, structs, test and a stub that answers notDrawn(tabClosed)): cmux-tui-rust-check.sh test agent_cursor, step b8ba8d897ba6fcef94a9d3de, failed. 3 typed tests failed: vector replay, JSON agreement, bad input. The layout test and the existing JSON tests passed.
  • The first green round at 36495005df9 found that the new call indexed slices. The panic ratchet on the proof ref, step 0265e7ecf95d6286e7d8b471, counted indexing_slicing hits in cmux-layout-reducer-ffi: 4 in agent_cursor.rs (this PR) and 5 in sidebar_mirror.rs. The sidebar_mirror.rs hits are already on feat; that file isn't touched here and no ratchet counts that crate on feat. It also counted one arithmetic_side_effects of this PR. 124066d2af7 removes all of this PR's hits: every read is a checked get, the pane handle is a checked_add, the conversions use try_from, and an impossible window index answers ERR_INVALID. The step at 36495005df9 (8057d84448b2b2a72347d8f8) is cancelled, because that head is replaced.
  • 124066d2af7 didn't compile. A closure named shows hid the rule shows(rect, clip) (E0057, clippy 3ae289e4f44272fbb6e5a0f7, panic at that head 602d6163f76794906c574980). 2973a299dc6 renames it shows_workspace. The steps at that head are cancelled or replaced.
  • The proof-ref panic run at 956947aefd6 (7ddbad5edffa09e2067d89ed) counted 0 hits in agent_cursor.rs. It still counted 5 indexing_slicing hits in cmux-layout-reducer-ffi, all in sidebar_mirror.rs (lines 131, 161, 216, 223, 245). These are older hits, last changed by b66fc32c7ee. By the lead's order this PR fixes them too, in 571a44d0116: Mirror::apply_batch reads with Value::get and sets "dropped" through set_dropped. The answers stay the same, and the new test tests/sidebar_mirror_batch.rs pins them.
  • This fix keeps the same behavior, so the new test has to PASS on both the old and the new code, and the new code has to bring the proof-ref panic run to 0.
  • The test and the fix are one commit because the pre-commit hook refused the separate test commit: the crate still had the fix unstaged. A pushed commit isn't split, since that needs a force push.
  • Old code (old sidebar_mirror.rs plus the new test, never-merge ref a0672035f91): test sidebar_mirror_batch, step 8db0f754cc2932208ac6c1d9, passed (2 of 2). The answers pinned from the old code are the answers of the new code.
  • Green at 16df66048da (571a44d0116 on the proof ref, same trees): clippy, which is the compile, a3f475084b7419b96e4548bc; test agent_cursor 23f91f9069afb4b8d1ec2215 passed (10 of 10: the typed vectors, the JSON agreement, bad input, the header layout, and the JSON op and rules); test sidebar_mirror_batch c8763fb83d67022cc9457db1 passed (2 of 2); panic ratchet 1955895eadeec6d6e34a121c: cmux-layout-reducer-ffi shows 0 indexing_slicing. The run is red only on the base red cmux-rd-link 2 (comment above). Clippy a3f475084b7419b96e4548bc passed.
  • Panic ratchet at this exact head 571a44d0116, which doesn't count the FFI crate: step 0bba00f98332c8ca75703b5f: red only on the base red cmux-rd-link 2. At 2973a299dc6 (d493a83461ff7f5ce99c0579) its only red was the base red cmux-rd-link: clippy::indexing_slicing 2, which #32559 fixes (comment 6588758367).
  • Earlier, at 2973a299dc6's proof ref 956947aefd6: clippy 56f109a85d57fa6be42f9d71 passed and test agent_cursor e4a1abb12bf465e3a631ed93 passed.
  • Clippy at 36495005df9: d2794f2b4aee6930a3af2003 passed.
  • prepush-gate.py: no new violations.

Review follow-up at 544894685e2 (lead order):

  • The header now names the anchor and reason numbers: CMUX_CURSOR_ANCHOR_* and CMUX_CURSOR_REASON_*. The Rust side uses constants of the same names. A new test checks that every CMUX_CURSOR_* define equals the Rust constant of the same name, so a hand-written number that drifts fails a test. Step B adds the same check on the Swift side, against the header the module map exposes.
  • The new vector row_scrolled_out_bottom produces anchor 5 (columnEdge.bottom), and a new test checks that the vectors produce all eight anchors. The rules' answers don't change; an independent Python copy of the rules also gives all 27 vectors.
  • Red at 4ce37ac5041 (the two tests and the Rust constants, with no header constants and no new vector): test agent_cursor_typed, step 3b3cfaf9e28741be9ddac0e0, failed: the 2 new tests failed and the other 4 passed.
  • Green at 91b8699ce49 (544894685e2 on the proof ref, same trees): test agent_cursor 12513a33e35827a246f7b72c, clippy c47af9e76b8640181562a631, panic a7f7e7df7a4b7c0924832f0d. Panic at the head: 357ef43debbe4065ab060c96. Results: test passed 12 of 12; clippy passed; both panic runs are red only on the base red cmux-rd-link 2 (#32559), and cmux-layout-reducer-ffi shows 0 indexing_slicing.
  • Head c605febc8f5 is cmux-bot's merge of feat ecce1e0d8f9. This PR's diff is unchanged, and the merge brings in #32559. The panic ratchet at this head, step f065bfde57d8f7b7a29e7295, PASSED ("cmux-tui-rust-check: green"). The base red is gone. Receipt: SHIP at c605febc8f5 (comment 6590643422).
  • The review receipt at 571a44d0116 doesn't cover this head. A new review runs here.

Not run:

  • Swift: this PR changes no Swift.
  • The typed call's own speed: step B measures it on an M6 mini against the old Swift resolver and reports it next to the JSON numbers above.