feat: pin the controlling IDE from the menu bar - #99
Conversation
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 48 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (14)
Comment |
There was a problem hiding this comment.
Pull request overview
This PR adds a user-selectable “controller lock” so the deck can be pinned to a chosen IDE family from the tray menu (instead of being inferred from the frontmost app), and centralizes IDE identity into a single shared registry in mb-protocol so the daemon and UI can’t disagree about which IDE is “live”.
Changes:
- Introduces
mb_protocol::ide::IDESas the canonical registry (family/label/aliases/providers/profile), used by both daemon matching and the tray “Controlled by” submenu. - Adds
controlling_ideto daemon config and threads the controller lock through focus resolution, Agent Key resolution, and input handling (including per-IDE input profiles + a realnavigationcapability gate). - Updates docs + UI type mirrors to reflect new capabilities/config fields and the controller-lock behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| docs/protocol.md | Documents controlling_ide, the controller-lock focus policy, and adds navigation capability semantics. |
| docs/design/README.md | Adds design explanation for pinning controller IDE vs frontmost inference. |
| docs/adapters.md | Documents IDE families registry (IDES), controller lock behavior, and navigation/profiles rationale. |
| crates/microbridged/src/state.rs | Wires controller lock into daemon behavior (frontmost handling, selectable sessions) and adds tests for navigation + profiles + registry/provider coherence. |
| crates/microbridged/src/registry.rs | Implements active_controller, controller-scoped focus resolution, and controller-scoped selectable session lists. |
| crates/microbridged/src/key_source.rs | Threads controller lock into focused_app key-source resolution while leaving other key sources unchanged. |
| crates/microbridged/src/app_match.rs | Delegates family matching to mb_protocol::ide to keep daemon/UI in sync; adds matcher consistency tests. |
| crates/mb-protocol/src/lib.rs | Adds ide module, adds navigation capability + gating, adds controlling_ide config field + normalization validation + tests. |
| crates/mb-protocol/src/ide.rs | Adds canonical IDE registry, matching (family_for_app), and per-IDE input profile types/defaults + tests. |
| apps/microbridge-ui/src/surfaces/surfaces.test.tsx | Updates snapshot fixture for new controlling_ide config field. |
| apps/microbridge-ui/src/lib/types.ts | Updates TS mirrors (AdapterCapabilities, DaemonConfig, SessionStatus.focus_uri) to match protocol additions. |
| apps/microbridge-ui/src/lib/bus.ts | Updates demo snapshot config to include controlling_ide. |
| apps/microbridge-ui/src-tauri/src/lib.rs | Adds tray “Controlled by” submenu, tooltip/status syncing, and persistence of controlling_ide. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn ide_has_enabled_provider(snapshot: &Snapshot, providers: &[&str]) -> bool { | ||
| providers.iter().any(|provider| { | ||
| snapshot | ||
| .config | ||
| .adapters | ||
| .get(*provider) | ||
| .is_some_and(|preference| preference.enabled) | ||
| }) | ||
| } |
Which IDE owns the deck was inferred, never chosen. An NSWorkspace watcher polls
the frontmost app every 400 ms into the runtime-only `config.frontmost_app`, and
both the focus policy and the Agent Key resolver scoped the deck to whatever that
said.
That inference is unreliable by construction, because hosts and harnesses are
many-to-many. On a live bus right now, T3 Code threads arrive from *both* the
`claude` journal watcher (Agent SDK sessions under ~/.t3/worktrees) and the
`codex` journal watcher (originator t3code) — and could also arrive from the
`t3code` paired control plane. Frontmost tells you which window is up, not which
integration should be driven, and it changes on every alt-tab.
Adds a "Controlled by" submenu to the tray. Left on Automatic, the deck follows
the frontmost app exactly as before. Pinned, it stays on that IDE until changed.
One registry replaces three ID namespaces. The same IDE was named three ways with
nothing tying them together: session label "T3 Code", family key "t3", adapter id
"t3code". `mb_protocol::ide::IDES` is now the single source of truth, and owns the
family matcher so the daemon and the menu bar app cannot disagree about whether a
pinned IDE is live. Two tests pin the tables together.
It lives in mb-protocol, not microbridged, because `DaemonConfig::normalize()`
must validate the family and the Tauri app depends on mb-protocol only.
Enforcement is one predicate — `Registry::active_controller`, which returns the
pinned family only while it has live sessions — threaded through four sites:
resolve_focus, key_source::focused_app, move_focus (it cycled an unfiltered
session list, so the first joystick flick would have walked off the pinned IDE),
and set_frontmost_app.
Two behavior changes are intentional and documented: an approval in another IDE no
longer preempts, and `pinned_focus` is ignored while it points outside the
controller. When the pinned IDE has no live sessions the lock yields to the normal
policy rather than going dark, and the tray says so.
Also fixes a capability lie. `AdapterCapabilities::supports()` returned true
unconditionally for the four Navigate* actions — harmless only while nothing
emitted them, but the first profile to do so would have had the action accepted
and then silently dropped. `navigation` is now a real, default-false capability.
`CycleFocus` stays unconditional; it is deck-local.
Adds the per-IDE `IdeProfile` seam (dial / joystick / dial_press), resolved from
the focused session's family so profiles apply in Automatic mode too; pinning is
what makes which profile you get predictable. Every IDE currently uses
DEFAULT_PROFILE, which reproduces the previous input map exactly. Cursor stays
default deliberately: it exposes no navigation surface Microbridge may drive, and
its dial behavior is already correct via capability negotiation.
Tested end to end against a real daemon on an isolated HOME/socket/config, driving
the socket protocol with fake adapters — 13 checks, all passing: the lock holds
through a foreign-IDE approval, same-family approvals still preempt, a codex-fed
T3 thread lands on the deck beside t3code-fed ones, an emptied pin falls back and
then reclaims, the value survives restart, and a hand-edited bad family is cleared.
Tray verified in a dev build: the tooltip tracks the pin, including the fallback
case ("Microbridge — OpenCode (idle · following Cursor)"), and instrumentation
confirmed all 11 retained CheckMenuItems are updated on the main thread.
Also fixes the stale TS capability mirror (tty_control, mcp_native, uri_focus were
missing) and the protocol.md capability list, which was missing the same three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
373ea34 to
c25b4eb
Compare
Why
Which IDE owns the deck was inferred, never chosen. An
NSWorkspacewatcherpolls the frontmost app every 400 ms into the runtime-only
config.frontmost_app,and both the focus policy and the Agent Key resolver scoped the deck to that.
That inference is unreliable by construction, because hosts and harnesses are
many-to-many. Measured on a live bus while writing this — T3 Code threads arriving
from two different harnesses at once:
claude(Agent SDK under~/.t3/worktrees)codex(originator: t3code)Frontmost tells you which window is up, not which integration should be driven —
and it changes on every alt-tab. So: pick it, and have it stay picked.
Left on Automatic every path is byte-identical to today.
One registry replaces three ID namespaces
The same IDE was named three ways with nothing tying them together — session label
"T3 Code", family key"t3", adapter id"t3code".mb_protocol::ide::IDESisnow the single source of truth (
family/label/aliases/providers/profile) and owns the family matcher, so the daemon and the menu bar app cannotdisagree about whether a pinned IDE is live.
providersis a list preciselybecause the relation is many-to-many.
It lives in
mb-protocol, notmicrobridged, becauseDaemonConfig::normalize()must validate the family and the Tauri app depends on
mb-protocolonly.Three tests exist to stop the tables drifting apart again: every
familyround-trips through
app_family(label), everyprovidersentry exists in theadapter registry, and the daemon and UI resolve families through one matcher.
Enforcement — one predicate, four sites
Registry::active_controllerreturns the pinned family only while it has livesessions; that is also the fallback mechanism. Threaded through
resolve_focus,key_source::focused_app,move_focus, andset_frontmost_app.move_focuswas the easy one to miss — it cycled an unfilteredsession_list(),so the first joystick flick would have walked the deck straight off the pinned IDE.
Two intentional behavior changes, both documented in
docs/protocol.md:awaiting_approvalsession in another IDE no longer preempts.pinned_focusis ignored while it points outside the controller.When the pinned IDE has no live sessions the lock yields rather than going
dark, and the tray says so.
Fixes a capability lie
AdapterCapabilities::supports()returnedtrueunconditionally for all fourNavigate*actions. Harmless only while nothing emitted them — but the firstprofile to do so would have had the action accepted and then silently dropped,
exactly the silent success
state.rsrefuses everywhere else.navigationis nowa real, default-false capability.
CycleFocusstays unconditional; it's deck-local.Per-IDE profile seam
IdeProfile(dial / joystick / dial_press) resolved from the focused session'sfamily, so profiles apply in Automatic mode too — pinning is what makes which
profile you get predictable. Every IDE currently uses
DEFAULT_PROFILE, whichreproduces the previous input map exactly (guarded by a test).
There is deliberately no "off" dial role: whether a host can act on a lever is
already answered dynamically by
AdapterCapabilities, and duplicating thatstatically would only go stale. Cursor therefore stays on the default profile —
it exposes no navigation surface Microbridge may drive, and synthesizing UI
keystrokes is not on the table.
How this was tested
End to end against a real daemon, isolated
HOME+ socket + config, drivingthe socket protocol with fake adapters. 13 checks, all passing:
codex:-fed T3 thread lands on the deck besidet3code:-fed onescontrolling_idesurvives daemon restart; hand-edited"nonsense"is clearedTray verified in a dev build. The tooltip tracks the pin through every state,
including the fallback:
Microbridge — OpenCode (idle · following Cursor).Temporary instrumentation confirmed all 11 retained
CheckMenuItems areupdated on the main thread on each config change.
Also:
cargo test --workspace(106),clippy -D warnings,cargo fmt --check,tsc --noEmit,npm test(30),npm run build, Tauri crate clippy — all clean.Fixes two stale mirrors found on the way: the TS
AdapterCapabilitieswas missingtty_control/mcp_native/uri_focus, and so was thedocs/protocol.mdcapability list.
Not verified
No hardware. With no Codex Micro attached, the profile seam's dial and joystick
paths are covered by unit tests but have not been driven by real input. The lock
itself doesn't touch the device layer.
Menu appearance not seen. Every code path is proven to execute with correct
data, but the rendered submenu (checkmark glyphs, layout) hasn't been eyeballed —
PyObjC wasn't available to synthesize the right-click that pops a detached NSMenu.
Merge order
Best merged after #98, whose
t3codechanges this PR'sdocs/adapters.mddescribes. #97 is independent.
🤖 Generated with Claude Code