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