From c25b4eb30fb36693dbeaf306719f31640ba123b2 Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Sat, 25 Jul 2026 09:21:19 -0400 Subject: [PATCH] feat: pin the controlling IDE from the menu bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/microbridge-ui/src-tauri/src/lib.rs | 277 +++++++++++++- apps/microbridge-ui/src/lib/bus.ts | 1 + .../src/lib/hardwareControl.test.ts | 1 + apps/microbridge-ui/src/lib/types.ts | 10 + .../src/surfaces/surfaces.test.tsx | 1 + crates/mb-protocol/src/ide.rs | 337 ++++++++++++++++++ crates/mb-protocol/src/lib.rs | 80 ++++- crates/microbridged/src/app_match.rs | 135 ++----- crates/microbridged/src/key_source.rs | 93 ++++- crates/microbridged/src/registry.rs | 236 +++++++++++- crates/microbridged/src/state.rs | 247 ++++++++++++- docs/adapters.md | 105 ++++++ docs/design/README.md | 10 + docs/protocol.md | 54 ++- 14 files changed, 1439 insertions(+), 148 deletions(-) create mode 100644 crates/mb-protocol/src/ide.rs diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index 8173541..c4fa3de 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -15,12 +15,18 @@ use std::time::Duration; use bus::{apply_event, spawn_bus_loop, BusHandle, CachedSnapshot}; use mb_protocol::{BusEvent, ClientMessage, DaemonConfig, ServerMessage, Snapshot}; use tauri::{ - menu::{ContextMenu, Menu, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, ContextMenu, Menu, MenuItem, PredefinedMenuItem, Submenu}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Emitter, LogicalSize, Manager, PhysicalPosition, Position, Size, WebviewWindow, }; use tokio::sync::Mutex; +/// Tray icon id, so the bus loop can retrieve it to refresh the tooltip. +const TRAY_ID: &str = "microbridge-tray"; +/// Menu item id prefix for the "Controlled by" submenu. +const CONTROLLER_PREFIX: &str = "controller:"; +const CONTROLLER_AUTO: &str = "controller:auto"; + struct AppState { bus: BusHandle, snapshot: CachedSnapshot, @@ -29,6 +35,18 @@ struct AppState { shutting_down: AtomicBool, } +/// Live handles into the "Controlled by" submenu. +/// +/// The submenu is built once during `setup`, so its items have to be retained +/// somewhere to keep checkmarks, labels and enablement in step with the daemon. +struct ControllerMenu { + /// Disabled first row: what actually owns the deck right now. With a + /// tray-only picker this is the one place a fallback can be explained. + status: MenuItem, + auto: CheckMenuItem, + ides: Vec<(&'static str, CheckMenuItem)>, +} + fn daemon_socket_path() -> PathBuf { if let Ok(path) = std::env::var("MICROBRIDGE_SOCKET") { return PathBuf::from(path); @@ -2022,6 +2040,186 @@ mod login_item_tests { } } +/// What the deck is actually following, which is not always what was pinned. +struct ControllerView { + /// Pinned family, if any. + pinned: Option, + /// True when the pinned IDE has at least one live session, i.e. the lock is + /// really in force. False means the daemon fell back to most-recent. + honored: bool, + /// Label of the IDE the deck ended up on while falling back. + following: Option, +} + +impl ControllerView { + fn of(snapshot: &Snapshot) -> Self { + let pinned = snapshot.config.controlling_ide.clone(); + // Same matcher the daemon's focus policy uses, so "is the lock live?" + // cannot be answered differently here than it was there. + let honored = pinned.as_deref().is_some_and(|family| { + snapshot + .sessions + .iter() + .any(|session| mb_protocol::ide::family_for_app(&session.app) == family) + }); + // Only meaningful while falling back; the daemon's focused session is + // the authority on where the deck actually went. + let following = (pinned.is_some() && !honored) + .then(|| { + let focused = snapshot.focused_session_id.as_deref()?; + let session = snapshot.sessions.iter().find(|s| s.id == focused)?; + Some(session.app.clone()) + }) + .flatten(); + Self { + pinned, + honored, + following, + } + } + + fn status_line(&self) -> String { + let Some(family) = self.pinned.as_deref() else { + return "Following the frontmost app".into(); + }; + let label = mb_protocol::ide::label_for_family(family); + if self.honored { + return format!("{label} owns the deck"); + } + match &self.following { + Some(following) => { + format!("{label} · no live threads — following {following}") + } + None => format!("{label} · no live threads"), + } + } + + fn tooltip(&self) -> String { + let Some(family) = self.pinned.as_deref() else { + return "Microbridge".into(); + }; + let label = mb_protocol::ide::label_for_family(family); + if self.honored { + return format!("Microbridge — {label}"); + } + match &self.following { + Some(following) => format!("Microbridge — {label} (idle · following {following})"), + None => format!("Microbridge — {label} (idle)"), + } + } +} + +/// An IDE is pickable when at least one adapter that can feed it is enabled. +/// Without this a user could pin an IDE that has no way to report a session and +/// see nothing happen. +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) + }) +} + +/// Push daemon state into the retained menu items and the tray tooltip. +fn sync_controller_menu(app: &AppHandle, snapshot: &Snapshot) { + let view = ControllerView::of(snapshot); + let status = view.status_line(); + let tooltip = view.tooltip(); + let pinned = view.pinned.clone(); + let enabled: Vec<(&'static str, bool)> = mb_protocol::IDES + .iter() + .map(|ide| { + ( + ide.family, + ide_has_enabled_provider(snapshot, ide.providers), + ) + }) + .collect(); + + let handle = app.clone(); + // AppKit menu mutation is main-thread-only; the bus loop is not the main thread. + let _ = app.run_on_main_thread(move || { + if let Some(tray) = handle.tray_by_id(TRAY_ID) { + let _ = tray.set_tooltip(Some(&tooltip)); + } + let Some(menu) = handle.try_state::() else { + // Expected only for events that beat `setup`'s `app.manage`; the next + // one catches up. If it persists the submenu is silently frozen, so + // say something rather than leaving stale checkmarks unexplained. + eprintln!("microbridge-ui: controller menu state unavailable; skipping sync"); + return; + }; + let _ = menu.status.set_text(&status); + let _ = menu.auto.set_checked(pinned.is_none()); + for (family, item) in &menu.ides { + let _ = item.set_checked(pinned.as_deref() == Some(*family)); + let has_provider = enabled + .iter() + .find(|(candidate, _)| candidate == family) + .map(|(_, value)| *value) + .unwrap_or(false); + // Keep a pinned IDE selectable even if its providers were since + // disabled, so the user can always see and clear the current choice. + let selectable = has_provider || pinned.as_deref() == Some(*family); + let _ = item.set_enabled(selectable); + let label = mb_protocol::ide::label_for_family(family); + let _ = item.set_text(if selectable { + label.to_string() + } else { + format!("{label} — enable in Settings") + }); + } + }); +} + +/// Persist a controller choice made from the tray. +fn choose_controller(app: &AppHandle, family: Option) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(state) = app.try_state::() else { + return; + }; + let Some(mut config) = state + .snapshot + .lock() + .await + .as_ref() + .map(|s| s.config.clone()) + else { + return; + }; + if config.controlling_ide == family { + return; + } + config.controlling_ide = family; + match state.bus.set_config(config).await { + Ok(next) => { + let mut guard = state.snapshot.lock().await; + if let Some(snapshot) = guard.as_mut() { + snapshot.config = next; + let payload = snapshot.clone(); + drop(guard); + let _ = app.emit("bus-snapshot", &payload); + sync_controller_menu(&app, &payload); + } + } + Err(error) => { + // The daemon rejected it — leave the checkmarks describing the + // config that is actually in force rather than the attempt. + eprintln!("microbridge-ui: could not set controlling IDE: {error}"); + let guard = state.snapshot.lock().await; + if let Some(snapshot) = guard.as_ref() { + let payload = snapshot.clone(); + drop(guard); + sync_controller_menu(&app, &payload); + } + } + } + }); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { if std::env::args().any(|argument| argument == "--unregister-login-item") { @@ -2105,6 +2303,7 @@ pub fn run() { last_focus = s.focused_session_id.clone(); saw_snapshot = true; *snap_for_loop.lock().await = Some(s.clone()); + sync_controller_menu(&handle, &s); let _ = handle.emit("bus-snapshot", &s); } ServerMessage::Event { event } => { @@ -2154,7 +2353,11 @@ pub fn run() { last_focus = s.focused_session_id.clone(); } let payload = s.clone(); - let _ = handle.emit("bus-snapshot", payload); + let _ = handle.emit("bus-snapshot", &payload); + // Sessions appearing/disappearing and focus moving + // both change whether the lock is being honored, so + // resync here rather than only on ConfigChanged. + sync_controller_menu(&handle, &payload); drop(guard); if changed && last_focus.is_some() { show_hud(&handle, Arc::clone(&hud_gen_loop)); @@ -2188,7 +2391,8 @@ pub fn run() { if let Some(s) = guard.as_mut() { s.config = config; let payload = s.clone(); - let _ = handle.emit("bus-snapshot", payload); + let _ = handle.emit("bus-snapshot", &payload); + sync_controller_menu(&handle, &payload); } } _ => {} @@ -2221,6 +2425,53 @@ pub fn run() { false, None::<&str>, )?; + // "Controlled by" pins one IDE to the deck. Ordered from the shared + // IDE registry rather than the Settings integration list, which is + // adapter-scoped and would list Cursor twice (`cursor`, `cursor_acp`). + let controller_status = MenuItem::with_id( + app, + "controller:status", + "Waiting for microbridged…", + false, + None::<&str>, + )?; + let controller_auto = CheckMenuItem::with_id( + app, + CONTROLLER_AUTO, + "Automatic (follow frontmost app)", + true, + true, + None::<&str>, + )?; + let mut controller_ides = Vec::with_capacity(mb_protocol::IDES.len()); + for ide in mb_protocol::IDES { + controller_ides.push(( + ide.family, + CheckMenuItem::with_id( + app, + format!("{CONTROLLER_PREFIX}{}", ide.family), + ide.label, + true, + false, + None::<&str>, + )?, + )); + } + let controller_submenu = { + let separator_one = PredefinedMenuItem::separator(app)?; + let separator_two = PredefinedMenuItem::separator(app)?; + let mut items: Vec<&dyn tauri::menu::IsMenuItem> = vec![ + &controller_status, + &separator_one, + &controller_auto, + &separator_two, + ]; + for (_, item) in &controller_ides { + items.push(item); + } + Submenu::with_items(app, "Controlled by", true, &items)? + }; + let check_updates_item = MenuItem::with_id( app, "check-updates", @@ -2235,7 +2486,9 @@ pub fn run() { let tray_menu = Menu::with_items( app, &[ + // Device state first, then what controls it, then utilities. &hardware_item, + &controller_submenu, &PredefinedMenuItem::separator(app)?, &check_updates_item, &settings_item, @@ -2246,10 +2499,16 @@ pub fn run() { let context_menu = tray_menu.clone(); let hardware_item_for_tray = hardware_item.clone(); + app.manage(ControllerMenu { + status: controller_status, + auto: controller_auto, + ides: controller_ides, + }); + let blur_hide: BlurHideClock = Arc::new(std::sync::Mutex::new(None)); let blur_hide_tray = Arc::clone(&blur_hide); - let _tray = TrayIconBuilder::new() + let _tray = TrayIconBuilder::with_id(TRAY_ID) .icon(tray_icon) .icon_as_template(true) .tooltip("Microbridge") @@ -2267,7 +2526,15 @@ pub fn run() { "check-updates" => trigger_update_check(app), "settings" => show_settings_window(app), "quit" => app.exit(0), - _ => {} + CONTROLLER_AUTO => choose_controller(app, None), + id => { + if let Some(family) = id.strip_prefix(CONTROLLER_PREFIX) { + // "controller:status" is disabled and never fires. + if mb_protocol::ide::is_known_family(family) { + choose_controller(app, Some(family.to_string())); + } + } + } }) .on_tray_icon_event(move |tray, event| match event { TrayIconEvent::Click { diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index a2de20a..c79b11d 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -43,6 +43,7 @@ const DEMO: Snapshot = { app_priority: [], custom_key_ids: ["", "", "", "", "", ""], pinned_focus: null, + controlling_ide: null, approvals_interrupt: true, pause_leds: false, appearance: "system", diff --git a/apps/microbridge-ui/src/lib/hardwareControl.test.ts b/apps/microbridge-ui/src/lib/hardwareControl.test.ts index bfd39a1..8e8af1d 100644 --- a/apps/microbridge-ui/src/lib/hardwareControl.test.ts +++ b/apps/microbridge-ui/src/lib/hardwareControl.test.ts @@ -24,6 +24,7 @@ function snapshot( app_priority: [], custom_key_ids: [], pinned_focus: null, + controlling_ide: null, approvals_interrupt: true, pause_leds: false, appearance: "system", diff --git a/apps/microbridge-ui/src/lib/types.ts b/apps/microbridge-ui/src/lib/types.ts index ad314af..96986fa 100644 --- a/apps/microbridge-ui/src/lib/types.ts +++ b/apps/microbridge-ui/src/lib/types.ts @@ -12,6 +12,7 @@ export interface SessionStatus { title: string; state: AgentState; updated_at_ms: number; + focus_uri?: string | null; } export type KeySource = @@ -42,6 +43,7 @@ export type AdapterConnectionState = | "incompatible" | "error"; +/** Mirrors `AdapterCapabilities` in mb-protocol; keep both in step. */ export interface AdapterCapabilities { lifecycle_observation: boolean; approval_acceptance: boolean; @@ -50,6 +52,12 @@ export interface AdapterCapabilities { new_session: boolean; focus_open: boolean; reasoning_effort: boolean; + // Added after this mirror was first written. Optional because older daemons + // omit them and every field is `#[serde(default)]` on the Rust side. + tty_control?: boolean; + mcp_native?: boolean; + uri_focus?: boolean; + navigation?: boolean; } export interface AdapterStatus { @@ -69,6 +77,8 @@ export interface DaemonConfig { app_priority: string[]; custom_key_ids: string[]; pinned_focus: string | null; + /** IDE family pinned to the deck from the tray; null = follow frontmost. */ + controlling_ide: string | null; approvals_interrupt: boolean; pause_leds: boolean; appearance: Appearance; diff --git a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx index 98ecc4d..a72a039 100644 --- a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx +++ b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx @@ -18,6 +18,7 @@ function snapshot(sessions: SessionStatus[] = []): Snapshot { app_priority: [], custom_key_ids: ["", "", "", "", "", ""], pinned_focus: null, + controlling_ide: null, approvals_interrupt: true, pause_leds: false, appearance: "system", diff --git a/crates/mb-protocol/src/ide.rs b/crates/mb-protocol/src/ide.rs new file mode 100644 index 0000000..0766f57 --- /dev/null +++ b/crates/mb-protocol/src/ide.rs @@ -0,0 +1,337 @@ +//! Canonical IDE registry — one table for a concept that had three vocabularies. +//! +//! The same IDE was previously named three different ways in three places: the +//! session `app` label (`"T3 Code"`), the focus family key (`"t3"`), and the +//! adapter id (`"t3code"`). Nothing tied them together, so they drifted. +//! +//! [`IDES`] is now the single source of truth. `app_match::canonical_family` +//! looks up labels here instead of keeping its own table, `DaemonConfig` +//! validates `controlling_ide` against it, and the menu bar app orders its +//! "Controlled by" submenu from it. +//! +//! # Why `providers` is a list +//! +//! Hosts and harnesses are many-to-many, and that is the whole reason the +//! controller has to be picked rather than inferred. A T3 Code session can +//! reach the bus from the `t3code` paired-HTTP control plane *or* from the +//! `codex` journal watcher (`originator: t3code…`) *or* from the `claude` +//! journal watcher (an Agent SDK session under `~/.t3/`). All three are the +//! same IDE to the user, so all three are listed under one family. + +use crate::Action; + +/// What dial rotation means for the focused thread's IDE. +/// +/// There is deliberately no `Off` variant. Whether an IDE can act on a lever is +/// already answered dynamically and correctly by `AdapterCapabilities`; encoding +/// it a second time as a static per-IDE fact would only add something new to go +/// stale the moment a host gains the capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialRole { + /// Rotate to step reasoning effort (the historical behavior). + Effort, + /// Rotate to move through the IDE's own navigation surface. + Navigate, +} + +/// What a joystick flick means for the focused thread's IDE. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoystickRole { + /// Cycle the local deck selection (the historical behavior). + DeckCycle, + /// Send navigation to the owning adapter. + Navigate, +} + +/// Per-IDE physical input behavior. +/// +/// The deck's input map used to be one hardcoded `match` that had to behave +/// identically everywhere, because the daemon could not be sure which IDE it +/// was talking to. A pinned controller removes that doubt, so the varying parts +/// live here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IdeProfile { + pub dial: DialRole, + pub joystick: JoystickRole, + pub dial_press: Action, +} + +/// The behavior every IDE had before profiles existed. Any IDE without an +/// explicit profile keeps exactly this, so adding the seam changed nothing. +pub const DEFAULT_PROFILE: IdeProfile = IdeProfile { + dial: DialRole::Effort, + joystick: JoystickRole::DeckCycle, + dial_press: Action::OpenFocusedThread, +}; + +/// One IDE family, and everything that identifies it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ide { + /// Stable internal key. Must equal `app_match::app_family(self.label)`. + pub family: &'static str, + /// Canonical session `app` label, and the "Controlled by" menu title. + pub label: &'static str, + /// Additional exact labels that mean the same IDE. Fuzzy matching (channel + /// suffixes like `"T3 Code (Nightly)"`, casual aliases like `"T3 Chat"`) + /// stays in `app_match`; this is only for distinct canonical spellings. + pub aliases: &'static [&'static str], + /// Adapter ids that can produce sessions for this IDE. Every entry must + /// exist in the daemon's adapter registry. + pub providers: &'static [&'static str], + pub profile: &'static IdeProfile, +} + +/// Menu order mirrors `INTEGRATION_ORDER` in the Settings surface, collapsed to +/// families — which is why Cursor appears once here despite having two adapters. +pub const IDES: &[Ide] = &[ + Ide { + family: "chatgpt", + label: "ChatGPT", + aliases: &["Codex Desktop"], + providers: &["chatgpt", "codex"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "claude_desktop", + label: "Claude Desktop", + aliases: &[], + providers: &["claude_desktop", "claude"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "claude_code", + label: "Claude Code", + aliases: &[], + providers: &["claude"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "codex", + label: "Codex CLI", + aliases: &[], + providers: &["codex"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "cnvs", + label: "CNVS", + aliases: &[], + providers: &["cnvs"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "synara", + label: "Synara", + aliases: &[], + providers: &["synara", "codex", "claude"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "conductor", + label: "Conductor", + aliases: &[], + providers: &["conductor", "codex", "claude"], + profile: &DEFAULT_PROFILE, + }, + // Cursor keeps the default profile on purpose. The `cursor` adapter is + // lifecycle-only hooks and `cursor_acp` explicitly does not remote-control + // an already-open composer, so there is no navigation surface to bind — and + // its dial behavior is already correct via capability negotiation. Revisit + // when Cursor ships a public navigation surface; synthesizing UI keystrokes + // is deliberately not on the table. + Ide { + family: "cursor", + label: "Cursor", + aliases: &["Cursor Agent (ACP)"], + providers: &["cursor", "cursor_acp", "codex", "claude"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "t3", + label: "T3 Code", + aliases: &[], + providers: &["t3code", "codex", "claude"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "factory", + label: "Factory", + aliases: &[], + providers: &["factory", "codex"], + profile: &DEFAULT_PROFILE, + }, + Ide { + family: "opencode", + label: "OpenCode", + aliases: &[], + providers: &["opencode"], + profile: &DEFAULT_PROFILE, + }, +]; + +pub fn ide_for_family(family: &str) -> Option<&'static Ide> { + IDES.iter().find(|ide| ide.family == family) +} + +/// Exact label → family, for the canonical spellings in [`IDES`]. +/// +/// Prefer [`family_for_app`] unless you specifically want exact matching. +pub fn family_for_label(label: &str) -> Option<&'static str> { + IDES.iter() + .find(|ide| ide.label == label || ide.aliases.contains(&label)) + .map(|ide| ide.family) +} + +/// Collapse any display name to a stable family key. +/// +/// Adapters emit the canonical labels in [`IDES`], but macOS frontmost names +/// carry channel suffixes (`"T3 Code (Nightly)"`) and casual aliases +/// (`"T3 Chat"`, bare `"Claude"`), so exact equality is not enough. +/// +/// This lives here rather than in the daemon because the menu bar app has to +/// decide whether a pinned IDE has live sessions and must reach the *same* +/// answer the daemon's focus policy did — two matchers that can disagree would +/// show a lock as inactive while the daemon was honoring it. +/// +/// Unrecognized names collapse to their lowercased selves, so an unknown +/// embedder keeps a stable identity of its own rather than being folded into +/// someone else's family. +pub fn family_for_app(name: &str) -> String { + let trimmed = name.trim(); + // Canonical labels are the common case — skip lowercasing/allocation work. + if let Some(family) = family_for_label(trimmed) { + return family.into(); + } + + let base = strip_channel_suffix(trimmed); + if let Some(family) = family_for_label(base) { + return family.into(); + } + + let lower = base.to_ascii_lowercase(); + if is_t3(&lower) { + return "t3".into(); + } + if lower == "cursor" || lower.starts_with("cursor ") { + return "cursor".into(); + } + if lower == "synara" || lower.starts_with("synara ") { + return "synara".into(); + } + if lower == "cnvs" || lower.starts_with("cnvs ") { + return "cnvs".into(); + } + if lower == "opencode" || lower.starts_with("opencode ") { + return "opencode".into(); + } + if is_chatgpt(&lower) { + return "chatgpt".into(); + } + if is_codex(&lower) { + return "codex".into(); + } + if is_claude_code(&lower) { + return "claude_code".into(); + } + if lower == "claude desktop" || lower.starts_with("claude desktop") { + return "claude_desktop".into(); + } + // "Claude Agent SDK" stays its own label (unknown embedders). + lower +} + +fn strip_channel_suffix(name: &str) -> &str { + // "T3 Code (Nightly)", "T3 Code (Alpha)", "Cursor (Dev)", … + if let Some(open) = name.rfind(" (") { + if name.ends_with(')') && open > 0 { + return &name[..open]; + } + } + name +} + +fn is_t3(lower: &str) -> bool { + matches!( + lower, + "t3" | "t3 code" | "t3chat" | "t3 chat" | "t3code" | "t3-code" + ) || lower.starts_with("t3 code") + || lower.starts_with("t3 chat") +} + +fn is_chatgpt(lower: &str) -> bool { + matches!(lower, "chatgpt" | "codex app" | "codex desktop") + || lower.starts_with("chatgpt ") + || lower.starts_with("codex desktop") +} + +fn is_codex(lower: &str) -> bool { + matches!(lower, "codex" | "codex cli") || lower.starts_with("codex cli") +} + +/// Frontmost often reports bare `"Claude"` while sessions are `"Claude Code"`. +fn is_claude_code(lower: &str) -> bool { + matches!( + lower, + "claude" | "claude code" | "claudecode" | "claude-code" + ) || (lower.starts_with("claude code") + && !lower.contains("desktop") + && !lower.contains("agent sdk")) +} + +/// Menu title for a family, falling back to the raw key so an unknown value is +/// visible rather than silently blank. +pub fn label_for_family(family: &str) -> &str { + ide_for_family(family).map_or(family, |ide| ide.label) +} + +pub fn is_known_family(family: &str) -> bool { + ide_for_family(family).is_some() +} + +/// Profile for a family; unknown families get the historical behavior. +pub fn profile_for_family(family: &str) -> &'static IdeProfile { + ide_for_family(family).map_or(&DEFAULT_PROFILE, |ide| ide.profile) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn families_and_labels_are_unique() { + for (index, ide) in IDES.iter().enumerate() { + let duplicate = IDES + .iter() + .skip(index + 1) + .any(|other| other.family == ide.family || other.label == ide.label); + assert!(!duplicate, "duplicate family/label for {}", ide.family); + } + } + + #[test] + fn every_label_and_alias_resolves_to_its_family() { + for ide in IDES { + assert_eq!(family_for_label(ide.label), Some(ide.family)); + for alias in ide.aliases { + assert_eq!(family_for_label(alias), Some(ide.family), "alias {alias}"); + } + } + assert_eq!(family_for_label("Nonexistent Editor"), None); + } + + #[test] + fn providers_are_never_empty() { + // A family with no provider could be pinned but never receive a + // session, which would look like a broken lock. + for ide in IDES { + assert!(!ide.providers.is_empty(), "{} has no providers", ide.family); + } + } + + #[test] + fn unknown_family_falls_back_to_default_profile() { + assert_eq!(profile_for_family("nonexistent"), &DEFAULT_PROFILE); + assert_eq!(label_for_family("nonexistent"), "nonexistent"); + assert!(!is_known_family("nonexistent")); + } +} diff --git a/crates/mb-protocol/src/lib.rs b/crates/mb-protocol/src/lib.rs index 6fd5ce0..6b9f64a 100644 --- a/crates/mb-protocol/src/lib.rs +++ b/crates/mb-protocol/src/lib.rs @@ -8,6 +8,10 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +pub mod ide; + +pub use ide::{DialRole, Ide, IdeProfile, JoystickRole, IDES}; + /// Protocol revision. Bumped on breaking changes; clients announce theirs in /// [`ClientMessage::Hello`]. pub const PROTOCOL_VERSION: u32 = 0; @@ -230,6 +234,11 @@ pub struct AdapterCapabilities { pub mcp_native: bool, #[serde(default)] pub uri_focus: bool, + /// Can act on [`Action::NavigateUp`] and friends — i.e. the host exposes a + /// navigation surface Microbridge may drive. Defaults false, so an adapter + /// that predates the field is never sent navigation it would drop. + #[serde(default)] + pub navigation: bool, } impl AdapterCapabilities { @@ -252,6 +261,7 @@ impl AdapterCapabilities { tty_control: true, mcp_native: true, uri_focus: true, + navigation: true, } } @@ -263,11 +273,16 @@ impl AdapterCapabilities { Action::NewSession => self.new_session, Action::OpenFocusedThread => self.focus_open, Action::ReasoningEffortUp | Action::ReasoningEffortDown => self.reasoning_effort, - Action::CycleFocus - | Action::NavigateUp + // Navigation has to be advertised like every other lever. It used + // to return `true` unconditionally, which was harmless only while + // nothing emitted it — now that a profile can, an unadvertised + // adapter would accept the action and silently drop it. + Action::NavigateUp | Action::NavigateDown | Action::NavigateLeft - | Action::NavigateRight => true, + | Action::NavigateRight => self.navigation, + // Deck-local: resolved by the daemon, never sent to an adapter. + Action::CycleFocus => true, } } } @@ -323,6 +338,14 @@ pub struct DaemonConfig { /// When set, this session owns the deck until cleared. #[serde(default, skip_serializing_if = "Option::is_none")] pub pinned_focus: Option, + /// Family key (see [`ide::IDES`]) of the IDE the user pinned to the deck + /// from the menu bar. `None` means follow the frontmost app, which is the + /// historical behavior. + /// + /// Unlike [`Self::frontmost_app`] this is a deliberate choice, so it is + /// persisted and survives restarts until the user changes it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub controlling_ide: Option, /// Approvals preempt focus (default true). #[serde(default = "default_true")] pub approvals_interrupt: bool, @@ -370,6 +393,7 @@ impl Default for DaemonConfig { app_priority: Vec::new(), custom_key_ids: vec![String::new(); AGENT_KEY_COUNT], pinned_focus: None, + controlling_ide: None, approvals_interrupt: true, pause_leds: false, appearance: Appearance::System, @@ -394,6 +418,15 @@ impl DaemonConfig { if let Some(colors) = self.lighting_preset.colors() { self.state_colors = colors; } + // A hand-edited or stale family key would match no session and wedge + // the deck. Drop it and fall back to following the frontmost app. + if self + .controlling_ide + .as_deref() + .is_some_and(|family| !ide::is_known_family(family)) + { + self.controlling_ide = None; + } } } @@ -564,6 +597,47 @@ mod tests { assert_eq!(serde_json::from_str::(&json).unwrap(), msg); } + #[test] + fn normalize_clears_an_unknown_controlling_ide() { + // A stale or hand-edited family would match no session, leaving the deck + // pinned to nothing. Falling back to Automatic is the safe reading. + let mut config = DaemonConfig { + controlling_ide: Some("nonsense".into()), + ..Default::default() + }; + config.normalize(); + assert_eq!(config.controlling_ide, None); + + let mut valid = DaemonConfig { + controlling_ide: Some("t3".into()), + ..Default::default() + }; + valid.normalize(); + assert_eq!(valid.controlling_ide.as_deref(), Some("t3")); + } + + #[test] + fn controlling_ide_is_omitted_from_config_when_unset() { + let json = serde_json::to_string(&DaemonConfig::default()).unwrap(); + assert!(!json.contains("controlling_ide"), "{json}"); + } + + #[test] + fn navigation_is_not_supported_unless_advertised() { + let lifecycle = AdapterCapabilities::lifecycle_only(); + for action in [ + Action::NavigateUp, + Action::NavigateDown, + Action::NavigateLeft, + Action::NavigateRight, + ] { + assert!(!lifecycle.supports(action), "{action:?}"); + assert!(AdapterCapabilities::full_control().supports(action)); + } + // Deck-local, resolved by the daemon — never gated on an adapter. + assert!(lifecycle.supports(Action::CycleFocus)); + } + #[test] fn title_defaults_to_empty() { let json = r#"{"type":"status","session":{"id":"x:1","app":"X","state":"idle","updated_at_ms":0}}"#; diff --git a/crates/microbridged/src/app_match.rs b/crates/microbridged/src/app_match.rs index 4b99e9f..c25f948 100644 --- a/crates/microbridged/src/app_match.rs +++ b/crates/microbridged/src/app_match.rs @@ -4,6 +4,9 @@ //! frontmost names often carry channel suffixes (`"T3 Code (Nightly)"`) or //! casual aliases (`"T3 Chat"`). Exact string equality breaks `focused_app`; //! compare via [`same_app`] instead. +//! +//! The matching itself lives in `mb_protocol::ide` so the menu bar app resolves +//! families exactly as the daemon does. These are the daemon-side names for it. /// True when two app names refer to the same IDE family. pub fn same_app(a: &str, b: &str) -> bool { @@ -15,102 +18,7 @@ pub fn same_app(a: &str, b: &str) -> bool { /// Collapse display / frontmost names to a stable family key. pub fn app_family(name: &str) -> String { - let trimmed = name.trim(); - // Common canonical session labels — skip lowercasing/allocation work. - if let Some(family) = canonical_family(trimmed) { - return family.into(); - } - - let base = strip_channel_suffix(trimmed); - if let Some(family) = canonical_family(base) { - return family.into(); - } - - let lower = base.to_ascii_lowercase(); - if is_t3(&lower) { - return "t3".into(); - } - if lower == "cursor" || lower.starts_with("cursor ") { - return "cursor".into(); - } - if lower == "synara" || lower.starts_with("synara ") { - return "synara".into(); - } - if lower == "cnvs" || lower.starts_with("cnvs ") { - return "cnvs".into(); - } - if lower == "opencode" || lower.starts_with("opencode ") { - return "opencode".into(); - } - if is_chatgpt(&lower) { - return "chatgpt".into(); - } - if is_codex(&lower) { - return "codex".into(); - } - if is_claude_code(&lower) { - return "claude_code".into(); - } - if lower == "claude desktop" || lower.starts_with("claude desktop") { - return "claude_desktop".into(); - } - // "Claude Agent SDK" stays its own label (unknown embedders). - lower -} - -fn canonical_family(name: &str) -> Option<&'static str> { - match name { - "T3 Code" => Some("t3"), - "Cursor" => Some("cursor"), - "Synara" => Some("synara"), - "Conductor" => Some("conductor"), - "Factory" => Some("factory"), - "CNVS" => Some("cnvs"), - "OpenCode" => Some("opencode"), - "Codex CLI" => Some("codex"), - "ChatGPT" | "Codex Desktop" => Some("chatgpt"), - "Claude Code" => Some("claude_code"), - "Claude Desktop" => Some("claude_desktop"), - _ => None, - } -} - -fn strip_channel_suffix(name: &str) -> &str { - // "T3 Code (Nightly)", "T3 Code (Alpha)", "Cursor (Dev)", … - if let Some(open) = name.rfind(" (") { - if name.ends_with(')') && open > 0 { - return &name[..open]; - } - } - name -} - -fn is_t3(lower: &str) -> bool { - matches!( - lower, - "t3" | "t3 code" | "t3chat" | "t3 chat" | "t3code" | "t3-code" - ) || lower.starts_with("t3 code") - || lower.starts_with("t3 chat") -} - -fn is_chatgpt(lower: &str) -> bool { - matches!(lower, "chatgpt" | "codex app" | "codex desktop") - || lower.starts_with("chatgpt ") - || lower.starts_with("codex desktop") -} - -fn is_codex(lower: &str) -> bool { - matches!(lower, "codex" | "codex cli") || lower.starts_with("codex cli") -} - -/// Frontmost often reports bare `"Claude"` while sessions are `"Claude Code"`. -fn is_claude_code(lower: &str) -> bool { - matches!( - lower, - "claude" | "claude code" | "claudecode" | "claude-code" - ) || (lower.starts_with("claude code") - && !lower.contains("desktop") - && !lower.contains("agent sdk")) + mb_protocol::ide::family_for_app(name) } #[cfg(test)] @@ -144,6 +52,41 @@ mod tests { assert!(same_app("Codex Desktop", "ChatGPT")); } + /// The registry claims `Ide::family` equals `app_family(Ide::label)`. If that + /// ever stops holding, a pinned controller silently matches no session — so + /// assert it rather than trusting the two tables to stay in step. + #[test] + fn every_registered_ide_round_trips_label_to_family() { + for ide in mb_protocol::IDES { + assert_eq!( + app_family(ide.label), + ide.family, + "{} label does not resolve to its own family", + ide.label + ); + for alias in ide.aliases { + assert_eq!(app_family(alias), ide.family, "alias {alias}"); + } + } + } + + /// The menu bar app resolves families through the same `mb_protocol::ide` + /// entry point, so a pinned IDE can never look live to one side and idle to + /// the other. + #[test] + fn daemon_and_ui_share_one_matcher() { + for name in [ + "T3 Code", + "T3 Code (Nightly)", + "Cursor Agent (ACP)", + "Claude", + "Codex", + "Claude Agent SDK", + ] { + assert_eq!(app_family(name), mb_protocol::ide::family_for_app(name)); + } + } + #[test] fn claude_frontmost_matches_claude_code() { assert!(same_app("Claude", "Claude Code")); diff --git a/crates/microbridged/src/key_source.rs b/crates/microbridged/src/key_source.rs index 2226424..9ef2385 100644 --- a/crates/microbridged/src/key_source.rs +++ b/crates/microbridged/src/key_source.rs @@ -2,18 +2,24 @@ use mb_protocol::{AgentState, DaemonConfig, KeySource, SessionStatus, AGENT_KEY_COUNT}; -use crate::app_match::same_app; +use crate::app_match::{app_family, same_app}; /// Fill six Agent Key slots from the session bus + config. +/// +/// `controller` is the pinned IDE family when one is set *and* live (see +/// `Registry::active_controller`). It scopes `FocusedApp` only: `Pinned` and +/// `Custom` are explicit per-session user intent, and `MostRecent` is +/// deliberately cross-app as the monitoring surface. pub fn resolve_agent_keys( sessions: &[SessionStatus], focused_session_id: Option<&str>, + controller: Option<&str>, config: &DaemonConfig, ) -> [Option; AGENT_KEY_COUNT] { let mut slots = [None, None, None, None, None, None]; let ids = match config.key_source { KeySource::MostRecent => most_recent(sessions), - KeySource::FocusedApp => focused_app(sessions, focused_session_id, config), + KeySource::FocusedApp => focused_app(sessions, focused_session_id, controller, config), KeySource::Pinned => pinned(sessions, config), KeySource::Priority => priority(sessions, config), KeySource::Custom => custom(config), @@ -33,8 +39,20 @@ fn most_recent(sessions: &[SessionStatus]) -> Vec> { fn focused_app( sessions: &[SessionStatus], focused_session_id: Option<&str>, + controller: Option<&str>, config: &DaemonConfig, ) -> Vec> { + // A pinned controller is the answer to "which IDE?" — it beats both the + // focused session and the frontmost app, which is the point of pinning. + if let Some(family) = controller { + let mut sorted: Vec<_> = sessions + .iter() + .filter(|s| app_family(&s.app) == family) + .collect(); + sorted.sort_by_key(|b| std::cmp::Reverse(b.updated_at_ms)); + return pad(sorted.into_iter().map(|s| Some(s.id.clone())).collect()); + } + let app = focused_session_id .and_then(|id| sessions.iter().find(|s| s.id == id)) .map(|s| s.app.as_str()) @@ -137,7 +155,7 @@ mod tests { key_source: KeySource::MostRecent, ..Default::default() }; - let keys = resolve_agent_keys(&sessions, Some("a"), &config); + let keys = resolve_agent_keys(&sessions, Some("a"), None, &config); assert_eq!(keys[0].as_deref(), Some("b")); assert_eq!(keys[1].as_deref(), Some("c")); assert_eq!(keys[2].as_deref(), Some("a")); @@ -153,7 +171,7 @@ mod tests { ]; // Default key source is focused_app: IDE-scoped, newest first. let config = DaemonConfig::default(); - let keys = resolve_agent_keys(&sessions, Some("c1"), &config); + let keys = resolve_agent_keys(&sessions, Some("c1"), None, &config); assert_eq!(keys[0].as_deref(), Some("c1")); assert_eq!(keys[1].as_deref(), Some("c2")); assert!(keys[2].is_none()); @@ -172,12 +190,12 @@ mod tests { ..Default::default() }; // No focused session → frontmost Nightly still scopes to T3 Code threads. - let keys = resolve_agent_keys(&sessions, None, &config); + let keys = resolve_agent_keys(&sessions, None, None, &config); assert_eq!(keys[0].as_deref(), Some("t1")); assert_eq!(keys[1].as_deref(), Some("t2")); assert!(keys[2].is_none()); - let cursor_focus = resolve_agent_keys(&sessions, Some("c1"), &config); + let cursor_focus = resolve_agent_keys(&sessions, Some("c1"), None, &config); assert_eq!(cursor_focus[0].as_deref(), Some("c1")); assert!(cursor_focus[1].is_none()); } @@ -193,12 +211,71 @@ mod tests { frontmost_app: Some("Claude".into()), ..Default::default() }; - let keys = resolve_agent_keys(&sessions, None, &config); + let keys = resolve_agent_keys(&sessions, None, None, &config); assert_eq!(keys[0].as_deref(), Some("cl1")); assert_eq!(keys[1].as_deref(), Some("cl2")); assert!(keys[2].is_none()); } + #[test] + fn pinned_controller_beats_a_conflicting_frontmost_app() { + let sessions = vec![ + session("t1", "T3 Code", AgentState::Working, 5), + session("t2", "T3 Code", AgentState::Idle, 4), + session("c1", "Cursor", AgentState::Working, 9), + ]; + let config = DaemonConfig { + frontmost_app: Some("Cursor".into()), + controlling_ide: Some("t3".into()), + ..Default::default() + }; + // Frontmost says Cursor and the focused session is Cursor; the lock wins. + let keys = resolve_agent_keys(&sessions, Some("c1"), Some("t3"), &config); + assert_eq!(keys[0].as_deref(), Some("t1")); + assert_eq!(keys[1].as_deref(), Some("t2")); + assert!(keys[2].is_none()); + } + + /// The many-to-many case the whole feature exists for: one IDE, three + /// harnesses. Locking "t3" must collect the T3 control plane's thread, the + /// Codex journal thread T3 spawned, and the Agent SDK thread under `~/.t3`. + #[test] + fn pinned_controller_spans_every_harness_feeding_that_ide() { + let sessions = vec![ + session("t3code:paired", "T3 Code", AgentState::Working, 9), + session("codex:originator", "T3 Code", AgentState::Working, 8), + session("claude:sdk", "T3 Code", AgentState::Idle, 7), + session("codex:cli", "Codex CLI", AgentState::Working, 10), + ]; + let config = DaemonConfig { + controlling_ide: Some("t3".into()), + ..Default::default() + }; + let keys = resolve_agent_keys(&sessions, None, Some("t3"), &config); + assert_eq!(keys[0].as_deref(), Some("t3code:paired")); + assert_eq!(keys[1].as_deref(), Some("codex:originator")); + assert_eq!(keys[2].as_deref(), Some("claude:sdk")); + // The bare Codex CLI thread is a different IDE and stays off the deck. + assert!(keys[3].is_none()); + } + + #[test] + fn pinned_controller_does_not_touch_cross_app_key_sources() { + let sessions = vec![ + session("t1", "T3 Code", AgentState::Working, 1), + session("c1", "Cursor", AgentState::Working, 2), + ]; + // MostRecent is the deliberate cross-app monitoring surface. + let config = DaemonConfig { + key_source: KeySource::MostRecent, + controlling_ide: Some("t3".into()), + ..Default::default() + }; + let keys = resolve_agent_keys(&sessions, None, Some("t3"), &config); + assert_eq!(keys[0].as_deref(), Some("c1")); + assert_eq!(keys[1].as_deref(), Some("t1")); + } + #[test] fn custom_assignments_honored() { let sessions = vec![session("a", "Codex", AgentState::Working, 1)]; @@ -214,7 +291,7 @@ mod tests { ], ..Default::default() }; - let keys = resolve_agent_keys(&sessions, None, &config); + let keys = resolve_agent_keys(&sessions, None, None, &config); assert!(keys[0].is_none()); assert_eq!(keys[1].as_deref(), Some("a")); } diff --git a/crates/microbridged/src/registry.rs b/crates/microbridged/src/registry.rs index c962d18..ec61df6 100644 --- a/crates/microbridged/src/registry.rs +++ b/crates/microbridged/src/registry.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use mb_protocol::{AgentState, DaemonConfig, SessionStatus}; -use crate::app_match::same_app; +use crate::app_match::{app_family, same_app}; use crate::key_source; #[derive(Debug, Default)] @@ -42,15 +42,39 @@ impl Registry { self.resolve_focus(config); } - /// Focus policy: + /// The pinned controller, but only while it actually has live sessions. + /// + /// Returning `None` when the pinned IDE is empty is what makes the deck fall + /// back to the most recent thread instead of going dark — a lock on an IDE + /// you closed hours ago would leave the hardware inert with no explanation. + /// The menu bar app surfaces the fallback in the tray so it stays legible. + pub fn active_controller<'a>(&self, config: &'a DaemonConfig) -> Option<&'a str> { + let family = config.controlling_ide.as_deref()?; + self.sessions + .values() + .any(|session| app_family(&session.app) == family) + .then_some(family) + } + + /// Focus policy, applied within the controlling IDE when one is pinned: /// 1. pinned_focus if still alive /// 2. awaiting_approval preempts (when approvals_interrupt) /// 3. current focus keeps the deck while it exists /// 4. frontmost app's most recent session (auto-follow via watcher) /// 5. most recently updated session + /// + /// A pinned controller filters every step, so an approval in another IDE no + /// longer preempts and the frontmost watcher no longer moves the deck. That + /// is the lock doing its job, not a bug. pub fn resolve_focus(&mut self, config: &DaemonConfig) { + let controller = self.active_controller(config); + let eligible = |session: &SessionStatus| match controller { + Some(family) => app_family(&session.app) == family, + None => true, + }; + if let Some(pin) = &config.pinned_focus { - if self.sessions.contains_key(pin) { + if self.sessions.get(pin).is_some_and(eligible) { self.focused = Some(pin.clone()); return; } @@ -61,6 +85,7 @@ impl Registry { .sessions .values() .filter(|s| s.state == AgentState::AwaitingApproval) + .filter(|s| eligible(s)) .max_by_key(|s| s.updated_at_ms); if let Some(session) = approval { self.focused = Some(session.id.clone()); @@ -69,26 +94,31 @@ impl Registry { } if let Some(id) = &self.focused { - if self.sessions.contains_key(id) { + if self.sessions.get(id).is_some_and(eligible) { return; } } - if let Some(app) = &config.frontmost_app { - let front = self - .sessions - .values() - .filter(|s| same_app(&s.app, app)) - .max_by_key(|s| s.updated_at_ms); - if let Some(session) = front { - self.focused = Some(session.id.clone()); - return; + // Skipped while pinned: the whole point is that alt-tabbing no longer + // moves the deck. + if controller.is_none() { + if let Some(app) = &config.frontmost_app { + let front = self + .sessions + .values() + .filter(|s| same_app(&s.app, app)) + .max_by_key(|s| s.updated_at_ms); + if let Some(session) = front { + self.focused = Some(session.id.clone()); + return; + } } } self.focused = self .sessions .values() + .filter(|s| eligible(s)) .max_by_key(|s| s.updated_at_ms) .map(|s| s.id.clone()); } @@ -99,7 +129,29 @@ impl Registry { pub fn agent_key_ids(&self, config: &DaemonConfig) -> [Option; 6] { let list: Vec<_> = self.sessions.values().cloned().collect(); - key_source::resolve_agent_keys(&list, self.focused.as_deref(), config) + key_source::resolve_agent_keys( + &list, + self.focused.as_deref(), + self.active_controller(config), + config, + ) + } + + /// Sessions the deck may select, newest first. Honors the controller lock so + /// joystick cycling cannot walk off the pinned IDE. + pub fn selectable_sessions(&self, config: &DaemonConfig) -> Vec { + let controller = self.active_controller(config); + let mut list: Vec<_> = self + .sessions + .values() + .filter(|session| match controller { + Some(family) => app_family(&session.app) == family, + None => true, + }) + .cloned() + .collect(); + list.sort_by_key(|b| std::cmp::Reverse(b.updated_at_ms)); + list } pub fn session_list(&self) -> Vec { @@ -128,6 +180,20 @@ mod tests { } } + fn app_session(id: &str, app: &str, state: AgentState, at: u64) -> SessionStatus { + SessionStatus { + app: app.into(), + ..session(id, state, at) + } + } + + fn pinned_to(family: &str) -> DaemonConfig { + DaemonConfig { + controlling_ide: Some(family.into()), + ..Default::default() + } + } + #[test] fn most_recent_session_gets_initial_focus() { let mut registry = Registry::default(); @@ -162,6 +228,148 @@ mod tests { assert_eq!(registry.focused, None); } + #[test] + fn pinned_controller_keeps_the_deck_through_another_ides_approval() { + let mut registry = Registry::default(); + let config = pinned_to("t3"); + registry.upsert( + app_session("t1", "T3 Code", AgentState::Working, 1), + 1, + &config, + ); + registry.upsert( + app_session("c1", "Cursor", AgentState::AwaitingApproval, 2), + 1, + &config, + ); + // The headline behavior change: approvals elsewhere no longer preempt. + assert_eq!(registry.focused.as_deref(), Some("t1")); + } + + #[test] + fn pinned_controller_still_honors_approvals_inside_its_own_family() { + let mut registry = Registry::default(); + let config = pinned_to("t3"); + registry.upsert( + app_session("t1", "T3 Code", AgentState::Working, 1), + 1, + &config, + ); + registry.upsert( + app_session("t2", "T3 Code", AgentState::AwaitingApproval, 2), + 1, + &config, + ); + assert_eq!(registry.focused.as_deref(), Some("t2")); + } + + #[test] + fn pinned_controller_ignores_the_frontmost_watcher() { + let mut registry = Registry::default(); + let config = DaemonConfig { + frontmost_app: Some("Cursor".into()), + ..pinned_to("t3") + }; + registry.upsert( + app_session("c1", "Cursor", AgentState::Working, 2), + 1, + &config, + ); + registry.upsert( + app_session("t1", "T3 Code", AgentState::Working, 1), + 1, + &config, + ); + assert_eq!(registry.focused.as_deref(), Some("t1")); + } + + /// Sessions can reach one family through several adapters — a T3 thread may + /// arrive from the `t3code` control plane or the `codex` journal watcher. + /// The lock is on the family, so it must collect both. + #[test] + fn pinned_controller_collects_every_provider_for_its_family() { + let mut registry = Registry::default(); + let config = pinned_to("t3"); + registry.upsert( + app_session("codex:abc", "T3 Code", AgentState::Working, 1), + 1, + &config, + ); + registry.upsert( + app_session("t3code:xyz", "T3 Code", AgentState::Working, 2), + 1, + &config, + ); + registry.upsert( + app_session("c1", "Cursor", AgentState::Working, 9), + 1, + &config, + ); + + let selectable = registry.selectable_sessions(&config); + assert_eq!(selectable.len(), 2); + assert!(selectable.iter().all(|s| s.app == "T3 Code")); + } + + #[test] + fn empty_controller_falls_back_to_most_recent() { + let mut registry = Registry::default(); + let config = pinned_to("t3"); + registry.upsert( + app_session("c1", "Cursor", AgentState::Working, 1), + 1, + &config, + ); + registry.upsert( + app_session("c2", "Cursor", AgentState::Working, 2), + 1, + &config, + ); + // No T3 thread exists, so the lock yields rather than going dark. + assert!(registry.active_controller(&config).is_none()); + assert_eq!(registry.focused.as_deref(), Some("c1")); + + // …and reclaims the deck the moment T3 comes back. + registry.upsert( + app_session("t1", "T3 Code", AgentState::Working, 3), + 1, + &config, + ); + assert_eq!(registry.focused.as_deref(), Some("t1")); + } + + #[test] + fn pinned_focus_is_ignored_when_it_points_outside_the_controller() { + let mut registry = Registry::default(); + let config = DaemonConfig { + pinned_focus: Some("c1".into()), + ..pinned_to("t3") + }; + registry.upsert( + app_session("c1", "Cursor", AgentState::Working, 2), + 1, + &config, + ); + registry.upsert( + app_session("t1", "T3 Code", AgentState::Working, 1), + 1, + &config, + ); + assert_eq!(registry.focused.as_deref(), Some("t1")); + } + + #[test] + fn unset_controller_is_never_active() { + let mut registry = Registry::default(); + let config = DaemonConfig::default(); + registry.upsert( + app_session("t1", "T3 Code", AgentState::Working, 1), + 1, + &config, + ); + assert!(registry.active_controller(&config).is_none()); + } + #[test] fn pinned_focus_beats_approval() { let mut registry = Registry::default(); diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs index d7c3c0c..67a7b22 100644 --- a/crates/microbridged/src/state.rs +++ b/crates/microbridged/src/state.rs @@ -9,12 +9,13 @@ use mb_adapters::{ObservedSession, SessionContext}; use mb_device::{parse_rgb_hex, Device, LedFrame}; use mb_protocol::{ Action, AdapterCapabilities, AdapterConnectionState, AdapterKind, AdapterStatus, AgentKeyLed, - AgentKeyLedFrame, AgentState, BusEvent, DaemonConfig, ServerMessage, SessionStatus, Snapshot, - AGENT_KEY_COUNT, + AgentKeyLedFrame, AgentState, BusEvent, DaemonConfig, DialRole, IdeProfile, JoystickRole, + ServerMessage, SessionStatus, Snapshot, AGENT_KEY_COUNT, }; use tokio::sync::{mpsc, Mutex}; use tracing::warn; +use crate::app_match::app_family; use crate::config::save_config; use crate::registry::Registry; @@ -606,17 +607,26 @@ impl DaemonState { } /// Update the ephemeral frontmost app (not written to disk). + /// + /// Still recorded while an IDE is pinned — Settings displays it — but the + /// focus re-resolve is skipped, so alt-tabbing costs nothing and cannot move + /// the deck. pub fn set_frontmost_app(&mut self, app: Option) { if self.config.frontmost_app == app { return; } let prev_focus = self.registry.focused.clone(); self.config.frontmost_app = app; - self.registry.resolve_focus(&self.config); + let pinned = self.registry.active_controller(&self.config).is_some(); + if !pinned { + self.registry.resolve_focus(&self.config); + } self.broadcast_ui(BusEvent::ConfigChanged { config: Box::new(self.config.clone()), }); - self.after_bus_change(prev_focus); + if !pinned { + self.after_bus_change(prev_focus); + } } fn after_bus_change(&mut self, prev_focus: Option) { @@ -766,7 +776,29 @@ impl DaemonState { } } + /// Input behavior for whichever IDE currently owns the deck. + /// + /// Keyed on the focused session's family rather than `config.controlling_ide` + /// so per-IDE behavior also applies in Automatic mode. Pinning a controller + /// is what makes *which* profile you get predictable, which is the whole + /// reason the profiles are safe to diverge at all. + fn active_profile(&self) -> &'static IdeProfile { + self.registry + .focused_session() + .map(|session| mb_protocol::ide::profile_for_family(&app_family(&session.app))) + .unwrap_or(&mb_protocol::ide::DEFAULT_PROFILE) + } + pub fn handle_device_input(&mut self, input: mb_device::DeviceInput) { + let profile = self.active_profile(); + self.handle_device_input_with_profile(input, profile); + } + + fn handle_device_input_with_profile( + &mut self, + input: mb_device::DeviceInput, + profile: &IdeProfile, + ) { use mb_device::{DeviceInput, JoystickDir}; match input { DeviceInput::AgentKeyPress { index } => { @@ -794,17 +826,28 @@ impl DaemonState { DeviceInput::Interrupt => self.handle_device_action(Action::Interrupt), DeviceInput::NewSession => self.handle_device_action(Action::NewSession), DeviceInput::CycleFocus | DeviceInput::TouchTap => self.move_focus(1), - DeviceInput::DialRotate { delta } if delta < 0 => { - self.handle_device_action(Action::ReasoningEffortDown) - } - DeviceInput::DialRotate { delta } if delta > 0 => { - self.handle_device_action(Action::ReasoningEffortUp) + DeviceInput::DialRotate { delta } if delta != 0 => { + let backward = delta < 0; + self.handle_device_action(match (profile.dial, backward) { + (DialRole::Effort, true) => Action::ReasoningEffortDown, + (DialRole::Effort, false) => Action::ReasoningEffortUp, + (DialRole::Navigate, true) => Action::NavigateUp, + (DialRole::Navigate, false) => Action::NavigateDown, + }) } DeviceInput::DialRotate { .. } => {} - DeviceInput::DialPress => self.handle_device_action(Action::OpenFocusedThread), - DeviceInput::JoystickFlick { direction } => match direction { - JoystickDir::Up | JoystickDir::Left => self.move_focus(-1), - JoystickDir::Down | JoystickDir::Right => self.move_focus(1), + DeviceInput::DialPress => self.handle_device_action(profile.dial_press), + DeviceInput::JoystickFlick { direction } => match profile.joystick { + JoystickRole::DeckCycle => match direction { + JoystickDir::Up | JoystickDir::Left => self.move_focus(-1), + JoystickDir::Down | JoystickDir::Right => self.move_focus(1), + }, + JoystickRole::Navigate => self.handle_device_action(match direction { + JoystickDir::Up => Action::NavigateUp, + JoystickDir::Down => Action::NavigateDown, + JoystickDir::Left => Action::NavigateLeft, + JoystickDir::Right => Action::NavigateRight, + }), }, } } @@ -899,7 +942,9 @@ impl DaemonState { } fn move_focus(&mut self, offset: isize) { - let sessions = self.registry.session_list(); + // Controller-scoped: cycling through every session would walk the deck + // straight off the pinned IDE on the first joystick flick. + let sessions = self.registry.selectable_sessions(&self.config); if sessions.is_empty() { return; } @@ -1138,6 +1183,23 @@ mod tests { DaemonState::new(Box::::default(), DaemonConfig::default()) } + /// The IDE registry names the adapters that can feed each family, and the + /// tray greys out a family whose providers are all disabled. A typo there + /// would silently make an IDE unpickable, so pin the two tables together. + #[test] + fn every_ide_provider_is_a_real_adapter() { + let adapters = initial_adapter_statuses(&DaemonConfig::default()); + for ide in mb_protocol::IDES { + for provider in ide.providers { + assert!( + adapters.contains_key(*provider), + "{} lists unknown provider {provider}", + ide.family + ); + } + } + } + #[test] fn hardware_control_retries_when_requested_but_disconnected() { let disabled = DaemonConfig::default(); @@ -1392,6 +1454,163 @@ mod tests { assert!(error.contains("does not support")); } + /// Navigation used to be reported as universally supported, so it would + /// pass the capability gate and then be dropped by an adapter that cannot + /// act on it — the silent success this daemon refuses everywhere else. + #[test] + fn navigation_requires_an_advertised_capability() { + let mut state = state(); + state.config.adapters.get_mut("cursor").unwrap().enabled = true; + let (tx, _rx) = mpsc::unbounded_channel(); + state + .register_adapter( + 42, + "cursor".into(), + Some("test".into()), + AdapterCapabilities::lifecycle_only(), + tx, + ) + .unwrap(); + state.upsert_session(session("cursor:one", AgentState::Working), 42); + let error = state + .route_action("cursor:one", Action::NavigateUp) + .unwrap_err(); + assert!(error.contains("does not support"), "got: {error}"); + } + + #[test] + fn navigation_is_delivered_when_advertised() { + let mut state = state(); + state.config.adapters.get_mut("cursor").unwrap().enabled = true; + let (tx, mut rx) = mpsc::unbounded_channel(); + state + .register_adapter( + 42, + "cursor".into(), + Some("test".into()), + AdapterCapabilities { + navigation: true, + ..AdapterCapabilities::lifecycle_only() + }, + tx, + ) + .unwrap(); + state.upsert_session(session("cursor:one", AgentState::Working), 42); + state + .route_action("cursor:one", Action::NavigateLeft) + .unwrap(); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::Action { + action: Action::NavigateLeft, + .. + }) + )); + } + + /// The profile seam must be a no-op for every IDE that has no explicit + /// profile — the dial keeps stepping reasoning effort and the joystick keeps + /// cycling the deck locally. + #[test] + fn default_profile_preserves_the_historical_input_map() { + use mb_device::{DeviceInput, JoystickDir}; + + let mut state = state(); + state.config.adapters.get_mut("cursor").unwrap().enabled = true; + let (tx, mut rx) = mpsc::unbounded_channel(); + state + .register_adapter( + 42, + "cursor".into(), + Some("test".into()), + AdapterCapabilities::full_control(), + tx, + ) + .unwrap(); + state.upsert_session(session("cursor:one", AgentState::Working), 42); + state.upsert_session(session("cursor:two", AgentState::Working), 42); + + state.handle_device_input(DeviceInput::DialRotate { delta: 1 }); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::Action { + action: Action::ReasoningEffortUp, + .. + }) + )); + state.handle_device_input(DeviceInput::DialRotate { delta: -1 }); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::Action { + action: Action::ReasoningEffortDown, + .. + }) + )); + + // Joystick stays deck-local: it moves focus, it does not reach the adapter. + let before = state.registry.focused.clone(); + state.handle_device_input(DeviceInput::JoystickFlick { + direction: JoystickDir::Down, + }); + assert_ne!(state.registry.focused, before); + assert!(rx.try_recv().is_err()); + } + + /// Exercises the `Navigate` roles end to end so the plumbing is known-good + /// before any IDE opts into it. + #[test] + fn navigate_profile_sends_navigation_instead_of_cycling() { + use mb_device::{DeviceInput, JoystickDir}; + use mb_protocol::ide::{DialRole, IdeProfile, JoystickRole}; + + const NAV: IdeProfile = IdeProfile { + dial: DialRole::Navigate, + joystick: JoystickRole::Navigate, + dial_press: Action::OpenFocusedThread, + }; + + let mut state = state(); + state.config.adapters.get_mut("cursor").unwrap().enabled = true; + let (tx, mut rx) = mpsc::unbounded_channel(); + state + .register_adapter( + 42, + "cursor".into(), + Some("test".into()), + AdapterCapabilities::full_control(), + tx, + ) + .unwrap(); + state.upsert_session(session("cursor:one", AgentState::Working), 42); + state.upsert_session(session("cursor:two", AgentState::Working), 42); + + let focused_before = state.registry.focused.clone(); + state.handle_device_input_with_profile( + DeviceInput::JoystickFlick { + direction: JoystickDir::Right, + }, + &NAV, + ); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::Action { + action: Action::NavigateRight, + .. + }) + )); + // Navigation goes to the IDE; the deck selection stays put. + assert_eq!(state.registry.focused, focused_before); + + state.handle_device_input_with_profile(DeviceInput::DialRotate { delta: -1 }, &NAV); + assert!(matches!( + rx.try_recv(), + Ok(ServerMessage::Action { + action: Action::NavigateUp, + .. + }) + )); + } + #[test] fn resolved_approval_cannot_be_routed() { let mut state = state(); diff --git a/docs/adapters.md b/docs/adapters.md index a84c7b1..4fb940b 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -72,3 +72,108 @@ separate pairable adapters. They share `~/.claude/projects` and a green / yellow / red status derived from live threads — do **not** open a PR that adds a Synara (or ChatGPT) pairing adapter unless the host publishes a distinct control API. + +## IDE families and the controller lock + +`mb_protocol::ide::IDES` is the single source of truth for IDE identity: it maps +each family key (`t3`) to its canonical session label (`"T3 Code"`), its aliases, +and the adapter ids that can feed it (`t3code`, `codex`, `claude`). That last +field is a list because hosts and harnesses are many-to-many — one IDE, several +possible session sources. + +Two tests keep the table honest and will fail your PR if you drift: every +`family` must round-trip through `app_match::app_family(label)`, and every +`providers` entry must exist in the daemon's adapter registry. If you add an +adapter for a new IDE, add its family here too — the tray's `Controlled by` +submenu is generated from this table, so an IDE missing from it cannot be pinned. + +## `navigation` capability and per-IDE profiles + +`Action::NavigateUp` / `NavigateDown` / `NavigateLeft` / `NavigateRight` are +gated on the `navigation` capability. It defaults to `false`, and an adapter that +does not advertise it gets an honest "does not support" error rather than having +the action accepted and dropped. Advertise it only when your host exposes a +navigation surface you actually drive. + +`IdeProfile` (also in `mb_protocol::ide`) binds the dial and joystick per IDE: + +| Field | Values | Default | +|---|---|---| +| `dial` | `Effort` · `Navigate` | `Effort` | +| `joystick` | `DeckCycle` · `Navigate` | `DeckCycle` | +| `dial_press` | any `Action` | `OpenFocusedThread` | + +The profile is chosen from the **focused session's** family, so it applies in +Automatic mode too; pinning a controller is what makes *which* profile you get +predictable. There is deliberately no "off" role — whether a host can act on a +lever is already answered dynamically by `AdapterCapabilities`, and duplicating +that as a static per-IDE fact would only go stale. + +Every IDE currently uses the default profile. Two notes on why: + +- **Cursor** has no navigation surface Microbridge may drive: the `cursor` + adapter is lifecycle-only hooks and `cursor_acp` explicitly does not + remote-control an open composer. Synthesizing UI keystrokes is not on the + table. Revisit when Cursor publishes one. +- **T3 Code** was checked against a live `0.0.29-nightly.20260725.899` + environment. **It exposes no navigation or selection command.** The tempting + `thread.jump.1` … `thread.jump.9` are `THREAD_JUMP_KEYBINDING_COMMANDS` — + local UI keybindings, not part of the dispatch contract — so binding them + would be exactly the keystroke synthesis this adapter refuses. See below for + what T3 *does* expose. + +### Re-checking the T3 contract + +The environment descriptor needs no auth, so the version and feature flags are +one command away: + +```bash +BASE=http://127.0.0.1:3774 # note: 3774, not the 3773 in the unit-test fixture +curl -s "$BASE/.well-known/t3/environment" | jq . +``` + +The full dispatch command union can be recovered from the app bundle without +pairing, which is how the list below was produced: + +```bash +ASAR="/Applications/T3 Code.app/Contents/Resources/app.asar" # or T3 Code (Nightly).app +LC_ALL=C strings -a "$ASAR" | grep -oE 'Schema\.Literal\("(thread|project)\.[a-z.-]+"\)' | sort -u +``` + +Compare the thread shape against `ThreadShell` in +`crates/microbridged/src/t3code.rs`. If `serverVersion` moves past the pinned +`SUPPORTED_SERVER_VERSIONS`, bump those and `PINNED_CONTRACT_COMMIT`; the version +gate fails closed to `Incompatible`, so an unbumped daemon is safe, just inert. + +**Dispatchable and already wired**: `thread.turn.interrupt`, +`thread.approval.respond` (decision is one of `accept` · `acceptForSession` · +`decline` · `cancel` — Microbridge uses `accept` / `decline`). + +**Dispatchable, advertised, not yet bound to a key** — the thread-lifecycle +surface, gated by the descriptor's `threadSettlement` / `threadSnooze` flags, +which the adapter now reads and reports in its diagnostic: + +| Command | Payload beyond `commandId` + `threadId` | +|---|---| +| `thread.settle` | — | +| `thread.unsettle` | `reason: "user"` | +| `thread.snooze` | `snoozedUntil: IsoDateTime` | +| `thread.unsnooze` | `reason: "user"` | +| `thread.archive` / `thread.unarchive` | — | + +**Deliberately still unadvertised**, with the reasons: + +- `reasoning_effort` stays `false`. T3 has **no reasoning-effort concept** — + `reasoningEffort` appears nowhere in the bundle, and `ModelSelection.options` + is still `Schema.Unknown`, which is the "provider option descriptors" the + capability comment waits on. The two near-misses are different things: + `RuntimeMode` is an autonomy ladder (`approval-required` · + `auto-accept-edits` · `auto` · `full-access`, set via + `thread.runtime-mode.set`) and `ProviderInteractionMode` is `default` · `plan` + (via `thread.interaction-mode.set`). Mapping effort onto either would be a + semantic lie; both would want their own `Action` if we bind them. +- `new_session` stays `false`. `thread.turn.start` needs an existing `threadId`, + or a `bootstrap.createThread` requiring `projectId`, `title` and a + `modelSelection` whose `model` is `Unknown`. Microbridge has no project or + model registry, so this needs a projects endpoint and a live paired + environment to verify against before it can be advertised honestly. diff --git a/docs/design/README.md b/docs/design/README.md index 04fc46c..b1cd84b 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -91,6 +91,16 @@ custom assignment. Agent Keys follow one IDE at a time by default; switch to most recent for a cross-app monitoring surface. Command keys always route to the single daemon-resolved focused thread. +Which IDE that is can also be **pinned** rather than inferred, from the tray's +`Controlled by` submenu. Left on *Automatic* the deck follows the frontmost app +as it always has; pinned, it stays on that IDE until you change it — alt-tabbing +and approvals elsewhere no longer move it. Because hosts and harnesses are +many-to-many (a T3 Code thread can arrive over T3's paired contract or through +the Codex or Claude journals), the pin is on the IDE family and collects every +harness feeding it. A pin whose IDE has no live threads yields to the normal +policy rather than leaving the deck dark, and the tray says so. Pinning is also +what makes per-IDE input behavior predictable enough to diverge at all. + The on-screen **device twin** is a photo-accurate vector rendering of the actual hardware — white plate (white in both themes), frosted agent caps with the switch stem visible through the frost, printed command icons, dial, diff --git a/docs/protocol.md b/docs/protocol.md index 3e899e5..df596da 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -41,8 +41,9 @@ processes. `capabilities` is an object whose canonical boolean keys all default to `false`: `lifecycle_observation`, `approval_acceptance`, -`approval_rejection`, `interrupt`, `new_session`, `focus_open`, and -`reasoning_effort`. The action mapping is: +`approval_rejection`, `interrupt`, `new_session`, `focus_open`, +`reasoning_effort`, `tty_control`, `mcp_native`, `uri_focus`, and +`navigation`. The action mapping is: | Action | Required capability | |---|---| @@ -52,8 +53,14 @@ processes. | `new_session` | `new_session` | | `open_focused_thread` | `focus_open` | | `reasoning_effort_up`, `reasoning_effort_down` | `reasoning_effort` | +| `navigate_up`, `navigate_down`, `navigate_left`, `navigate_right` | `navigation` | -Focus navigation is daemon-local and does not require a host capability. +`cycle_focus` moves the deck's own selection, so it is daemon-local and needs no +host capability. The `navigate_*` actions are different: they drive the host's +own navigation surface, so an adapter must advertise `navigation` to receive +them. Advertise it only if you act on them — the daemon returns an explicit +"does not support" rather than letting an unadvertised action be accepted and +silently dropped. ## Messages: adapter → daemon @@ -113,11 +120,18 @@ advertised the corresponding capability. Unknown actions remain a logged no-op. ``` Config fields include `key_source` (`most_recent` · `focused_app` · `pinned` · -`priority` · `custom`), `pinned_focus`, `approvals_interrupt`, `pause_leds`, -`appearance`, `lighting_preset`, `state_colors`, `brightness`, -`sleep_minutes`, `frontmost_app`, `hardware_control_enabled`, adapter consent, -and key-assignment lists. Persisted at -`~/.microbridge/config.toml`. +`priority` · `custom`), `pinned_focus`, `controlling_ide`, +`approvals_interrupt`, `pause_leds`, `appearance`, `lighting_preset`, +`state_colors`, `brightness`, `sleep_minutes`, `frontmost_app`, +`hardware_control_enabled`, adapter consent, and key-assignment lists. +Persisted at `~/.microbridge/config.toml`. + +`controlling_ide` is the IDE family key (`t3`, `cursor`, `claude_code`, … — see +`mb_protocol::ide::IDES`) the user pinned to the deck from the menu bar; `null` +means follow the frontmost app. An unrecognized value is cleared on load rather +than honored, so a stale or hand-edited key cannot wedge the deck. Unlike +`frontmost_app`, which is watcher-owned runtime state stripped before saving, +this is a deliberate choice and persists across restarts. ### Adapter consent and pairing @@ -184,6 +198,26 @@ with `{"type":"config_error","message":"…"}`. 4. Otherwise the frontmost app's most recent session (via `frontmost_app`). 5. Otherwise the most recently updated session. +### Controller lock + +When `controlling_ide` is set **and that IDE has at least one live session**, +every step above is restricted to sessions in that family, and step 4 is skipped +entirely. Two consequences are intentional, not defects: + +- An `awaiting_approval` session in a *different* IDE no longer preempts. +- `pinned_focus` is ignored while it points outside the controlling IDE. + +When the pinned IDE has **no** live sessions the lock yields and the unrestricted +policy applies, so the deck stays useful instead of going dark; it reclaims the +deck as soon as that IDE reports a session again. The menu bar app shows this +fallback in the tray tooltip and in the "Controlled by" submenu, since a silent +fallback would be indistinguishable from a broken lock. + +Because the lock is on the IDE *family*, it spans every harness feeding that IDE +— a `t3` lock collects T3 threads arriving from the `t3code` control plane, the +`codex` journal watcher (`originator: t3code…`) and the `claude` journal watcher +(Agent SDK sessions under `~/.t3/`) alike. + ## Key source (six Agent Keys) | Mode | Behavior | @@ -194,6 +228,10 @@ with `{"type":"config_error","message":"…"}`. | `priority` | Approvals / active / app-priority ordering | | `custom` | Explicit `custom_key_ids` (empty string = unassigned) | +A live `controlling_ide` scopes `focused_app` to the pinned family instead of the +focused/frontmost app. The other modes are untouched: `pinned` and `custom` are +explicit per-session intent, and `most_recent` is deliberately cross-app. + Command keys always route to the single focused session. ## Versioning