cmux-layout-reducer: sidebar row face rules and typed C ABI (step A of #31808, Rust only)

Merged
LA
4d ago
+2416-1

Step A of a two-step FFI move (backlog C2, rust-migration). The rules that decide what each sidebar row shows (which tab names it, its folder line, its program, its status line, its unread mark, each tab's shortened title) move from Swift (CmuxNextBridge/SidebarMapping*.swift) into Rust, in cmux_layout_reducer::sidebar_face, with the Swift tests ported as the Rust corpus, and a typed C ABI in cmux-layout-reducer-ffi (cmux_sidebar_face_*, no JSON). No Swift change and no behavior change here: nothing calls the new symbols until step B, #31808 (SidebarMapping calls the face, the Swift rules are deleted, the Copy Agent Identifier fix, the STRESS perf A/B), which lands after the app FFI re-pin is on feat.

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

The two crates here are byte for byte those of #31808's head cc8b828aea4, whose Rust half has been reviewed there.

What moves

cmux-tui/crates/cmux-layout-reducer/src/sidebar_face.rs and sidebar_face/text.rs:

Swift rule (SidebarMapping)Rust
frontTab, selected (window selection, else the clamped default tab; first pane on a focus tie)Workspace::front_tab
workTab (a page yields to the newest chat or terminal; page and app tabs give no name)Workspace::work_tab
labelTab, isIdle, bareShells (an idle shell or New Tab page yields to a chat, a working or waiting one first)Workspace::label_tab, is_idle, BARE_SHELLS
faceTabWorkspace::face_tab
chatTab, folderTab, the Group by Folder bucket (focused?.cwd ?? any tab's)chat_tab, folder_tab, Face::bucket_tab
agentTab (the harness icon lookup reaches Rust as Tab::branded)agent_tab
attentionReason / newestUnread (first on a tie), the unread count or dotnotice, unread
progress(_:tabs:), progress(TerminalProgressReport)progress, terminal_progress
kind, rowKind, listedKindkind, row_kind, listed_kind
processprocess
statusLine, entry, the ports/pr keys, the status block's entry filterstatus_line, entries_status_line, entry, is_status_entry
abbreviate (inside the row; the per-tab Swift callers keep their copy, see below), abbreviateRemote, shortenedPath, trimmingSlashabbreviate, abbreviate_remote, shorten, shortened_title, trim_slash
lastActivitylast_activity, Face::last_activity_ms
defaultWorkspaceName (Creating)default_workspace_name

face(&Workspace, &Context) -> Face runs them all for one row: O(that workspace's tabs).

What stays in Swift (also after step B), and why

  • sidebarTitle, isUnnamed: they read the page face (title, favicon), which the app's browser runtime computes for the tab this rule picks. Moving them would need a second call per row or every tab's page title in the request.
  • agentRowTitle, shortSessionName: they belong to the auto-name rules (WorkspaceAutoNameReader, and the daemon's own auto rename); they should move with those. agentRowTitle also uses Foundation's case-insensitive compare (full Unicode case folding), which str has no exact match for.
  • pageHost: Foundation URL host parsing; a hand port would differ on IDN and edge cases.
  • detail/activitySince (+Detail), presence (+Presence): they read acpmux's turn store, StatusMapping and SidebarTunables; StatusMapping is its own rule set.
  • Group mapping in sections (color tokens, custom #RRGGBB, collapse union): the group color enum is V-29 (one generated enum), not this slice.
  • machineIcon, spaces, creatingRow, icon, color, checklistItem, taskStatus: conversions into the app's drawing types.

Findings (differences found while mapping)

  1. The backlog names cmux-layout-reducer/src/rows.rs as the Rust owner of sidebar rows. It is not: rows.rs holds the rows of panes inside a layout column (InsertRow, MoveTabToRow). No SidebarMapping rule existed in Rust before this PR.
  2. sidebar_doc/view.rs computes only the structure of the rows (keys, sections, groups, collapse, filter). It takes the row title as an input (LiveWorkspace.title), which Swift computes. The typed rows reply of the mirror (view_typed, typed.rs) carries no workspace content, so this PR adds its own typed call instead of extending that reply.
  3. The daemon's Mux::default_workspace_name (cmux-tui-core mux/workspace_name.rs) is a second copy of defaultWorkspaceName that differs: it parses the number with usize::from_str, which takes workspace-+7 as 7 (Swift and the new Rust rule take ASCII digits only), it accepts numbers up to u64::MAX (Swift's Int stops at i64::MAX), and it knows no reserved names. Not changed here (that would change the daemon); a follow-up should make the daemon call this rule.
  4. Swift compares strings by canonical equivalence; Rust compares bytes. A title and a folder that are the same text in NFC and NFD were equal in Swift (process, isIdle, shortenedPath, abbreviate's prefix) and differ here. Both come from the same file system bytes in practice. Swift's split(separator: "/") in abbreviateRemote splits by grapheme, so a / followed by a combining mark was not a separator; Rust splits at every / byte.
  5. process, isIdle and folderTab shorten against this Mac's home even on another computer's row (Swift calls abbreviate, not the machine's shorten). So on a Cloud row, an idle shell at its home (/home/cmux) still gives a chat-named row its ~ folder line, which the same shell at this Mac's home does not. Kept as it is (no behavior change); a candidate fix for the Swift half's follow-up.
  6. defaultWorkspaceName adds 1 with Swift's trapping +; Rust saturates at i64::MAX.
  7. Progress values are not clamped in Rust: Bar.value and progress_value can leave 0...1 for an out-of-range report. Swift's SidebarProgress init clamps them, and the Swift half keeps that clamp. CTab.progress_value marks no value with CMUX_SIDEBAR_FACE_NO_VALUE (INT32_MIN); step B sends a negative report as 0, which is what that clamp made of it.
  8. The public Workspace model reads tabs with get(): pane tab counts that miss the tabs give no tab, never a panic (the FFI also refuses such a request).

The C ABI

cmux_sidebar_face_new/free/rows/default_name in include/cmux_layout_reducer_ffi.h: fixed-size request structs (workspaces, panes, tabs and status entries as runs of four arrays, byte ranges into one UTF-8 buffer) and a reply the handle owns (one face per workspace, one listed tab per tab, the status block's entry indexes, one string table). One call per frame takes every row the app's row feed marked changed, so the cost stays O(changed rows) for the 10k-row budget. A listed title is copied only when it changed.Each face also carries the work tab (workTab), which the app's page face and auto-namer read. A request with a range out of bounds, non-UTF-8 bytes or an unknown code answers ok 0 and no faces; the call never panics across the ABI.

Expected red: 'Check the app FFI pin' in 'cmux-next checks' (check-app-ffi-pin.sh), until app-ffi-release.yml publishes cmux-app-ffi-<sha> from feat-cmux-next and its re-pin PR lands, as on #29440.

Tests

Commit d8029be0cd5 ports the Swift tests (SidebarAbbreviateCostTests, SidebarTabHomeTitleTests, SidebarChatFrontTests, SidebarWorkTabTests, SidebarWorkTabIconTests, SidebarRowPageBesideChatTests, and the row facts SidebarMapping.row derives) with stub rule bodies (red); cf521ec7844 moves the rules (green); the next commits add the C ABI and its review fixes.

scripts/ci/cmux-tui-rust-check.sh on the fleet (Linux), at this branch:

  • red, test sidebar_face at d8029be0cd5: 82519bfc18ee178b4eb16e15: failed as expected, 38 tests ran, 34 failed on their assertions (the 4 that pass are cases whose title stays unchanged, which the stub also returns)
  • green, test sidebar_face at the head 704501f6789: 70790a874290ebe8028ba8f3: 39 of 39 passed
  • clippy (-D warnings) and the panic ratchet at the head: panic 8908a96116761a7c8d139042 passed; clippy 1628c8328f51120dd25417f1 red only on a base finding: cmux-tui-core/src/mux/agent_chat_notices.rs:147,209,1462 (from #31770, fixed on feat by #31907 after this branch's base 64b25664427). It checked cmux-layout-reducer with no finding. The head is now c469286f3b6, a clean merge of feat that brings #31907 and leaves the 8 files byte for byte the same; clippy there: dc640c8b4ae4bf31a22ea6c5 ran on a macOS worker; it checked cmux-layout-reducer with no finding and is red only on cmux-host/src/identity.rs:13,14,21,22 (macOS-unused imports and constants, a base red that #31928 fixed on feat after this head's base). Compile at the head: cmux-next-swift-check jobs 01078a92f34ed6b9a2e23ae8 and 96fb3bd59973b8689401a86a, both ok for the app and cmux-tui packages. Feat changed nothing these two crates build from between the two bases, so the red/green above (at d8029be0cd5 / 704501f6789) proves the head's Rust; a test sidebar_face at the head itself is submitted as a recheck

cmux-layout-reducer-ffi is its own Cargo workspace, which the fleet script does not build. Its tests (tests/sidebar_face.rs: struct sizes against the header, a two-row call, undecodable requests, the default name) and its clippy run on the proof-only ref rust-migration/sidebar-mapping-step-a-ffiproof (cd3e01df950: this head plus one never-merge commit that adds the crate to the cmux-tui workspace): test d0b1a73b6e24f78c93dacd49: 43 of 43 passed (the rule tests and the 4 sidebar_face_abi_* C ABI tests); clippy d7a354093dbba6b0aa6ea555 checked cmux-layout-reducer and cmux-layout-reducer-ffi with no finding, red only on the same base agent_chat_notices.rs. The same Rust passed there before on #31808's ffiproof4: test 1c19d178401f2ce9ce16d223 (43 of 43), clippy 1956528240165ea00f5b47b2.

Earlier evidence for the same rules on #31808: red bd28bcc956daaeb47b9f116e (34 of 38 failed on assertions; the 4 that pass are cases whose title stays unchanged, which the stub also returns).

Observed / Not run

Observed: the fleet steps above; python3 scripts/cmux-next/prepush-gate.py (no new violations).
Not run: no Swift build or suite (no Swift change); no perf A/B (no caller until step B). No local cargo on this VM (fleet only).

Changelog

none

Risk: high (the rules behind every sidebar row title once step B calls them). This PR alone changes no behavior.