Skip to content

feat: pin the controlling IDE from the menu bar - #99

Open
DevVig wants to merge 1 commit into
mainfrom
feat/controlling-ide-lock
Open

feat: pin the controlling IDE from the menu bar#99
DevVig wants to merge 1 commit into
mainfrom
feat/controlling-ide-lock

Conversation

@DevVig

@DevVig DevVig commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Why

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 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:

app label via adapter n
T3 Code claude (Agent SDK under ~/.t3/worktrees) 7
T3 Code codex (originator: t3code) 5

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.

⌥ right-click tray
  Controlled by      ▸ │ T3 Code owns the deck        (disabled status line)
  Check for Updates…   │ ─────────────────────────────
  Settings…            │ ✓ Automatic (follow frontmost app)
  ─────────────        │ ─────────────────────────────
  Quit Microbridge     │   T3 Code
                       │   Cursor  …

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::IDES is
now the single source of truth (family / label / aliases / providers /
profile) and owns the family matcher, so the daemon and the menu bar app cannot
disagree about whether a pinned IDE is live. providers is a list precisely
because the relation is many-to-many.

It lives in mb-protocol, not microbridged, because DaemonConfig::normalize()
must validate the family and the Tauri app depends on mb-protocol only.

Three tests exist to stop the tables drifting apart again: every family
round-trips through app_family(label), every providers entry exists in the
adapter registry, and the daemon and UI resolve families through one matcher.

Enforcement — one predicate, four sites

Registry::active_controller returns the pinned family only while it has live
sessions
; that is also the fallback mechanism. Threaded through resolve_focus,
key_source::focused_app, move_focus, and set_frontmost_app.

move_focus was the easy one to miss — it cycled an unfiltered session_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:

  • An awaiting_approval session in another IDE no longer preempts.
  • pinned_focus is 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() returned true unconditionally for all 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,
exactly the silent success state.rs refuses everywhere else. navigation is now
a real, default-false capability. CycleFocus stays unconditional; it's deck-local.

Per-IDE profile seam

IdeProfile (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 (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 that
statically 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, driving
the socket protocol with fake adapters. 13 checks, all passing:

  • 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
  • emptied pin falls back to Cursor, then reclaims when T3 returns
  • controlling_ide survives daemon restart; hand-edited "nonsense" is cleared

Tray 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 are
updated 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 AdapterCapabilities was missing
tty_control / mcp_native / uri_focus, and so was the docs/protocol.md
capability 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 t3code changes this PR's docs/adapters.md
describes. #97 is independent.

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 25, 2026 13:21
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 48 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d5d18f85-73ce-43b9-8a92-18a2993336a7

📥 Commits

Reviewing files that changed from the base of the PR and between fcd0aba and c25b4eb.

📒 Files selected for processing (14)
  • apps/microbridge-ui/src-tauri/src/lib.rs
  • apps/microbridge-ui/src/lib/bus.ts
  • apps/microbridge-ui/src/lib/hardwareControl.test.ts
  • apps/microbridge-ui/src/lib/types.ts
  • apps/microbridge-ui/src/surfaces/surfaces.test.tsx
  • crates/mb-protocol/src/ide.rs
  • crates/mb-protocol/src/lib.rs
  • crates/microbridged/src/app_match.rs
  • crates/microbridged/src/key_source.rs
  • crates/microbridged/src/registry.rs
  • crates/microbridged/src/state.rs
  • docs/adapters.md
  • docs/design/README.md
  • docs/protocol.md

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::IDES as the canonical registry (family/label/aliases/providers/profile), used by both daemon matching and the tray “Controlled by” submenu.
  • Adds controlling_ide to daemon config and threads the controller lock through focus resolution, Agent Key resolution, and input handling (including per-IDE input profiles + a real navigation capability 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.

Comment on lines +1454 to +1462
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>
@DevVig
DevVig force-pushed the feat/controlling-ide-lock branch from 373ea34 to c25b4eb Compare July 25, 2026 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants