From a87c1d1141e9fd5635dc0abb0f52269e8812616f Mon Sep 17 00:00:00 2001 From: Edwin Date: Wed, 9 Sep 2026 22:44:29 -0700 Subject: [PATCH 1/2] feat(cli): make Creator Micro session slots activity-aware --- crates/cli/src/app.rs | 148 ++++++++++++++++-- crates/cli/src/creator_micro.rs | 64 ++++---- docs/creator-micro.md | 24 +-- ...creator-micro-is-a-native-fleet-surface.md | 30 ++-- 4 files changed, 198 insertions(+), 68 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 655520c7..20a5926b 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -2650,6 +2650,9 @@ pub struct App { /// Native Work Louder surface link state. `None` means the opt-in surface /// is disabled; enabled surfaces report a filled or hollow modeline dot. pub creator_micro_link_connected: Option, + /// Stable hardware slots for the six most recently active user sessions. + /// Surviving assignments keep their physical position when recency changes. + pub creator_micro_session_slots: [Option; 6], /// Ambient Matrix-rain panel state for empty rows in the session list. pub matrix_rain: crate::matrix_rain::MatrixRain, /// Smoothed 0..1 foreground intensity for Matrix rain. The render path @@ -5944,6 +5947,7 @@ async fn run_with_socket_initial_selection( session_transitions: HashMap::new(), op_xy_link_connected: None, creator_micro_link_connected: None, + creator_micro_session_slots: Default::default(), matrix_rain: crate::matrix_rain::MatrixRain::default(), matrix_rain_intensity: 0.0, matrix_rain_intensity_updated_at: now, @@ -6722,6 +6726,9 @@ async fn run_loop( Some(crate::creator_micro::CreatorMicroEvent::Session(slot)) => { app.select_creator_micro_session(slot); } + Some(crate::creator_micro::CreatorMicroEvent::Pane(index)) => { + app.select_creator_micro_pane(index); + } Some(crate::creator_micro::CreatorMicroEvent::Enter) => { app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).await; } @@ -7224,22 +7231,68 @@ fn op_xy_slot_state_masks(sessions: &[SessionSummary], slots: &[Option]) }) } -fn creator_micro_session_slots(sessions: &[SessionSummary]) -> Vec { - sessions +fn creator_micro_activity_at_ms(session: &SessionSummary) -> Option { + [ + session.last_event_at.map(|at| at.timestamp_millis()), + session.last_message_at.map(|at| at.timestamp_millis()), + session.last_pty_at_ms, + ] + .into_iter() + .flatten() + .max() +} + +fn creator_micro_recent_session_ids(sessions: &[SessionSummary]) -> Vec { + let mut active = sessions .iter() - .filter(|session| !session.archived && is_user_list_session(session)) + .enumerate() + .filter_map(|(list_index, session)| { + if session.archived || !is_user_list_session(session) { + return None; + } + creator_micro_activity_at_ms(session) + .map(|activity_at| (list_index, activity_at, session.id.clone())) + }) + .collect::>(); + active.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + active + .into_iter() .take(6) - .map(|session| session.id.clone()) + .map(|(_, _, id)| id) .collect() } +fn reconcile_creator_micro_session_slots( + sessions: &[SessionSummary], + slots: &mut [Option; 6], +) { + let recent = creator_micro_recent_session_ids(sessions); + let recent_set = recent.iter().collect::>(); + for slot in slots.iter_mut() { + if slot.as_ref().is_some_and(|id| !recent_set.contains(id)) { + *slot = None; + } + } + for id in recent { + if slots.iter().flatten().any(|assigned| assigned == &id) { + continue; + } + if let Some(empty) = slots.iter_mut().find(|slot| slot.is_none()) { + *empty = Some(id); + } + } +} + fn creator_micro_snapshot_for_sessions( sessions: &[SessionSummary], + slots: &[Option; 6], ) -> crate::creator_micro::CreatorMicroSnapshot { use construct_protocol::SessionState; - let slots = creator_micro_session_slots(sessions); let mut snapshot = crate::creator_micro::CreatorMicroSnapshot::default(); for (slot, session_id) in slots.iter().enumerate() { + let Some(session_id) = session_id else { + continue; + }; let bit = 1 << slot; snapshot.assigned |= bit; let Some(session) = sessions.iter().find(|session| session.id == *session_id) else { @@ -15112,7 +15165,16 @@ impl App { } pub(crate) fn select_creator_micro_session(&mut self, slot: usize) { - let Some(session_id) = creator_micro_session_slots(&self.sessions).get(slot).cloned() else { + reconcile_creator_micro_session_slots( + &self.sessions, + &mut self.creator_micro_session_slots, + ); + let Some(session_id) = self + .creator_micro_session_slots + .get(slot) + .cloned() + .flatten() + else { self.set_status(format!("Creator Micro session key {} is unassigned", slot + 1)); return; }; @@ -15123,10 +15185,20 @@ impl App { self.set_status(format!("Creator Micro selected session {}", slot + 1)); } + pub(crate) fn select_creator_micro_pane(&mut self, index: usize) { + if !self.focus_pane_by_index(index) { + self.set_status(format!("Creator Micro split key {index} is unassigned")); + } + } + pub(crate) fn creator_micro_snapshot( - &self, + &mut self, ) -> crate::creator_micro::CreatorMicroSnapshot { - creator_micro_snapshot_for_sessions(&self.sessions) + reconcile_creator_micro_session_slots( + &self.sessions, + &mut self.creator_micro_session_slots, + ); + creator_micro_snapshot_for_sessions(&self.sessions, &self.creator_micro_session_slots) } pub(crate) fn op_xy_feedback_snapshot( @@ -18261,6 +18333,7 @@ mod tests { session_transitions: HashMap::new(), op_xy_link_connected: None, creator_micro_link_connected: None, + creator_micro_session_slots: Default::default(), matrix_rain: crate::matrix_rain::MatrixRain::default(), matrix_rain_intensity: 0.0, matrix_rain_intensity_updated_at: now, @@ -18470,20 +18543,44 @@ mod tests { } #[test] - fn creator_micro_slots_follow_live_user_list_order() { + fn creator_micro_slots_track_recent_activity_without_moving_survivors() { + let base = chrono::Utc::now(); let mut sessions = (0..8) .map(|index| { let mut session = summary_with_kind(construct_protocol::SessionKind::User); session.id = format!("s{index}"); + session.last_event_at = Some(base + chrono::Duration::seconds(index)); session }) .collect::>(); sessions[1].kind = construct_protocol::SessionKind::Subagent; sessions[2].archived = true; + let mut slots = Default::default(); + reconcile_creator_micro_session_slots(&sessions, &mut slots); + assert_eq!( + slots, + ["s7", "s6", "s5", "s4", "s3", "s0"].map(|id| Some(id.into())) + ); + + // A retained session becoming newest does not jump to a new key. + sessions[3].last_event_at = Some(base + chrono::Duration::seconds(20)); + reconcile_creator_micro_session_slots(&sessions, &mut slots); + assert_eq!( + slots, + ["s7", "s6", "s5", "s4", "s3", "s0"].map(|id| Some(id.into())) + ); + + // A newly active session replaces the least-recent member in-place; + // all five surviving physical assignments remain stable. + let mut newcomer = summary_with_kind(construct_protocol::SessionKind::User); + newcomer.id = "s8".into(); + newcomer.last_event_at = Some(base + chrono::Duration::seconds(30)); + sessions.push(newcomer); + reconcile_creator_micro_session_slots(&sessions, &mut slots); assert_eq!( - creator_micro_session_slots(&sessions), - vec!["s0", "s3", "s4", "s5", "s6", "s7"] + slots, + ["s7", "s6", "s5", "s4", "s3", "s8"].map(|id| Some(id.into())) ); } @@ -18501,9 +18598,17 @@ mod tests { attention.id = "attention".into(); attention.state = construct_protocol::SessionState::Done; attention.needs_attention = true; + let slots = [ + Some("idle".into()), + Some("active".into()), + Some("attention".into()), + None, + None, + None, + ]; assert_eq!( - creator_micro_snapshot_for_sessions(&[idle, active, attention]), + creator_micro_snapshot_for_sessions(&[idle, active, attention], &slots), crate::creator_micro::CreatorMicroSnapshot { assigned: 0b0000_0111, active: 0b0000_0010, @@ -36057,6 +36162,25 @@ mod tests { server.abort(); } + #[tokio::test] + async fn creator_micro_middle_row_focuses_split_pane_ordinals() { + let (mut app, _dir, server) = captured_app().await; + app.main_windows = three_window_tree(); + app.focus = PaneFocus::List; + + app.select_creator_micro_pane(2); + assert_eq!(app.focus, PaneFocus::View); + assert_eq!(app.active_window_id, 2); + + app.select_creator_micro_pane(4); + assert_eq!(app.active_window_id, 2); + assert_eq!( + app.status.as_ref().map(|(message, _)| message.as_str()), + Some("Creator Micro split key 4 is unassigned") + ); + server.abort(); + } + #[tokio::test] async fn focus_pane_index_zero_is_the_list() { let (mut app, _dir, server) = captured_app().await; diff --git a/crates/cli/src/creator_micro.rs b/crates/cli/src/creator_micro.rs index a6b2e6a7..290c4906 100644 --- a/crates/cli/src/creator_micro.rs +++ b/crates/cli/src/creator_micro.rs @@ -127,6 +127,7 @@ pub(crate) struct CreatorMicroSnapshot { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CreatorMicroEvent { Session(usize), + Pane(usize), Enter, Approve, Reject, @@ -300,7 +301,6 @@ fn run_connection( send_feedback(&device, *snapshot, &mut request_id)?; let mut last_feedback = Instant::now(); let mut reassembler = JsonReassembler::default(); - let mut wide_pressed_at: Option = None; let mut buffer = [0u8; REPORT_SIZE]; while !stop.load(Ordering::Relaxed) { @@ -327,7 +327,7 @@ fn run_connection( continue; } for message in reassembler.push(&buffer[3..3 + payload_len]) { - if let Some(event) = event_from_message(&message, &mut wide_pressed_at) { + if let Some(event) = event_from_message(&message) { let _ = event_tx.send(event); } } @@ -475,10 +475,7 @@ impl JsonReassembler { } } -fn event_from_message( - message: &Value, - wide_pressed_at: &mut Option, -) -> Option { +fn event_from_message(message: &Value) -> Option { if message.get("m")?.as_str()? != "v.oai.hid" { return None; } @@ -496,23 +493,13 @@ fn event_from_message( return Some(CreatorMicroEvent::Session(slot)); } Some(match key { - "ACT06" => CreatorMicroEvent::Enter, - "ACT07" => CreatorMicroEvent::Approve, - "ACT08" => CreatorMicroEvent::Reject, - "ACT09" => CreatorMicroEvent::Action(MidiAction::Interrupt), - // One wide physical cap can press ACT10 and ACT11 together. Treat the - // pair as one New Session gesture instead of opening two dialogs. - "ACT10" | "ACT11" => { - let now = Instant::now(); - if wide_pressed_at - .is_some_and(|last| now.duration_since(last) < Duration::from_millis(80)) - { - return None; - } - *wide_pressed_at = Some(now); - CreatorMicroEvent::Action(MidiAction::NewSession) - } - "ACT12" => CreatorMicroEvent::Action(MidiAction::CommandPalette), + "ACT06" => CreatorMicroEvent::Pane(1), + "ACT07" => CreatorMicroEvent::Pane(2), + "ACT08" => CreatorMicroEvent::Pane(3), + "ACT09" => CreatorMicroEvent::Pane(4), + "ACT10" => CreatorMicroEvent::Approve, + "ACT11" => CreatorMicroEvent::Reject, + "ACT12" => CreatorMicroEvent::Enter, "ENC_CC" => CreatorMicroEvent::Action(MidiAction::ScrollUp), "ENC_CW" => CreatorMicroEvent::Action(MidiAction::ScrollDown), "ENC_CLK" => CreatorMicroEvent::Action(MidiAction::SwitchFocus), @@ -588,34 +575,37 @@ mod tests { #[test] fn agent_and_action_keys_map_to_construct_semantics() { - let mut wide = None; let event = |key: &str| json!({"m":"v.oai.hid","p":{"k":key,"act":1}}); assert_eq!( - event_from_message(&event("AG05"), &mut wide), + event_from_message(&event("AG05")), Some(CreatorMicroEvent::Session(5)) ); assert_eq!( - event_from_message(&event("ACT07"), &mut wide), + event_from_message(&event("ACT07")), + Some(CreatorMicroEvent::Pane(2)) + ); + assert_eq!( + event_from_message(&event("ACT10")), Some(CreatorMicroEvent::Approve) ); assert_eq!( - event_from_message(&event("ENC_CW"), &mut wide), + event_from_message(&event("ACT11")), + Some(CreatorMicroEvent::Reject) + ); + assert_eq!( + event_from_message(&event("ACT12")), + Some(CreatorMicroEvent::Enter) + ); + assert_eq!( + event_from_message(&event("ENC_CW")), Some(CreatorMicroEvent::Action(MidiAction::ScrollDown)) ); } #[test] - fn releases_and_second_wide_switch_are_suppressed() { - let mut wide = None; + fn releases_are_suppressed() { let release = json!({"m":"v.oai.hid","p":{"k":"AG00","act":0}}); - assert_eq!(event_from_message(&release, &mut wide), None); - let first = json!({"m":"v.oai.hid","p":{"k":"ACT10","act":1}}); - let second = json!({"m":"v.oai.hid","p":{"k":"ACT11","act":1}}); - assert_eq!( - event_from_message(&first, &mut wide), - Some(CreatorMicroEvent::Action(MidiAction::NewSession)) - ); - assert_eq!(event_from_message(&second, &mut wide), None); + assert_eq!(event_from_message(&release), None); } #[test] diff --git a/docs/creator-micro.md b/docs/creator-micro.md index a88dc8e1..3f62195b 100644 --- a/docs/creator-micro.md +++ b/docs/creator-micro.md @@ -12,13 +12,11 @@ keycodes: | Physical control | Input keycode | Construct behavior | |---|---|---| -| Six agent keys | `KV_OAI_AG00` … `KV_OAI_AG05` | Select live sessions 1–6 | -| Play | `KV_OAI_ACT06` | Enter / submit | -| Approve | `KV_OAI_ACT07` | Answer yes | -| Reject | `KV_OAI_ACT08` | Answer no | -| Stop | `KV_OAI_ACT09` | Interrupt the selected session | -| Wide key (both switches) | `KV_OAI_ACT10`, `KV_OAI_ACT11` | New session (coalesced once) | -| Four-dot key | `KV_OAI_ACT12` | Command palette | +| Six agent keys | `KV_OAI_AG00` … `KV_OAI_AG05` | Select six recent sessions in stable hardware slots | +| Middle row | `KV_OAI_ACT06` … `KV_OAI_ACT09` | Focus split panes 1–4 | +| Bottom-left | `KV_OAI_ACT10` | Answer yes | +| Bottom-middle | `KV_OAI_ACT11` | Answer no | +| Bottom-right | `KV_OAI_ACT12` | Enter / submit | | Encoder left/right/click | `KV_OAI_ENC_CC`, `KV_OAI_ENC_CW`, `KV_OAI_ENC_CLK` | Scroll up/down; switch focus | The `KV_OAI_*` keycodes produce vendor events instead of ordinary keystrokes. @@ -44,9 +42,15 @@ construct means the feature is enabled and Construct is waiting for the sleeping or disconnected board. Bluetooth reconnects automatically. -The six agent keys follow the first six non-archived, top-level user sessions in -the visible Construct list order. Reordering the list also reorders the hardware -slots. Subagents, operators, and the minibuffer do not take a key. +The six agent keys track the six most recently active non-archived, top-level +user sessions. A session that remains in that set keeps its physical key even +when its recency rank changes. When a newly active session enters a full set, it +inherits the key vacated by the least-recent session. Subagents, operators, the +minibuffer, and sessions with no recorded activity do not take a key. + +`ACT10` and `ACT11` are independent yes/no inputs. A stock wide keycap can +actuate both switches together; use independently pressable keycaps for this +mapping so one gesture cannot send both answers. Key colours are: diff --git a/specs/0213-creator-micro-is-a-native-fleet-surface.md b/specs/0213-creator-micro-is-a-native-fleet-surface.md index aa22030d..c122b487 100644 --- a/specs/0213-creator-micro-is-a-native-fleet-surface.md +++ b/specs/0213-creator-micro-is-a-native-fleet-surface.md @@ -9,14 +9,18 @@ Scope: Work Louder Creator Micro 2 devices control and display one live Construc Construct supports the Creator Micro 2 as an opt-in native fleet surface on macOS. It opens the vendor HID report pipe non-exclusively over USB or Bluetooth -and never synthesizes desktop keyboard events. The six agent keys correspond to -the first six live top-level user sessions in the TUI's durable list order. +and never synthesizes desktop keyboard events. The six agent keys track the six +most recently active live top-level user sessions. A session keeps its physical +slot while it remains in that set; a newcomer replaces the least-recent member +in the vacated slot instead of reshuffling every surviving assignment. Each assigned key displays that session's state: dim blue is idle, breathing amber is running, and bright green needs attention. An unassigned key is off. Pressing an agent key selects that session in the active pane and gives its view -keyboard focus. Action keys and the encoder dispatch the same semantic actions -as Construct's keyboard, mouse, palette, and MIDI inputs. +keyboard focus. The four middle-row action keys focus split panes 1–4 in their +visible ordinal order. The three bottom-row switches dispatch yes, no, and +enter. The encoder dispatches the same scroll and focus actions as Construct's +keyboard, mouse, and MIDI inputs. The integration is disabled until the user opts in. Once enabled, a sleeping or disconnected wireless device is retried without blocking the TUI and has a @@ -31,9 +35,11 @@ channel preserves normal keyboard behavior and avoids Accessibility permission. Opt-in ownership matters because the device's six thread colours are global: two host integrations writing them concurrently would visibly fight. -List order is already persistent, user-controlled, and visible in Construct. -Reusing it makes the hardware mapping useful immediately without requiring -session-title conventions or a second mapping database. +Recency keeps the limited hardware surface pointed at sessions that are doing +work or asking for attention. Preserving the physical slot of every session +that remains in the top six avoids turning a rank change into a muscle-memory +change. Split-pane ordinals are already visible and shared across Construct +clients, so the middle row can target them without a second numbering scheme. ## Consequences @@ -44,6 +50,10 @@ session-title conventions or a second mapping database. ordinary keystrokes. - Archived sessions, subagents, operators, and the minibuffer do not consume one of the six fleet keys. +- Sessions with no recorded event, message, or PTY activity do not consume a + fleet key. +- The yes and no switches must be independently pressable. A keycap spanning + both physical switches can actuate contradictory answers in one gesture. - Construct does not rewrite device firmware or the saved keymap. Keymap setup remains an explicit user operation in Work Louder Input and can preserve unrelated profiles and layers. @@ -58,7 +68,9 @@ session-title conventions or a second mapping database. ## Examples -- Reordering a live session from list position 4 to position 1 moves its state - and selection gesture from agent key 4 to agent key 1. +- When a seventh session becomes more recent than key 4's session, it inherits + key 4; the sessions on the other five keys do not move. +- Middle-row key 3 focuses the split pane wearing ordinal badge 3 and reports an + unassigned key when fewer than three panes exist. - A sleeping Bluetooth device shows a hollow `micro` indicator; waking it reconnects and restores all six current states. From 45317af98b297fe1fa02f4dba002e413ed819ed2 Mon Sep 17 00:00:00 2001 From: Edwin Date: Wed, 9 Sep 2026 22:51:09 -0700 Subject: [PATCH 2/2] fix(cli): focus visible Creator Micro sessions in place --- crates/cli/src/app.rs | 69 ++++++++++++++++++- docs/creator-micro.md | 2 + ...creator-micro-is-a-native-fleet-surface.md | 10 +-- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 20a5926b..522b43d5 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -15178,8 +15178,19 @@ impl App { self.set_status(format!("Creator Micro session key {} is unassigned", slot + 1)); return; }; - self.select_session(session_id); - self.focus = PaneFocus::View; + if let Some(window_id) = self + .main_windows + .leaf_panes() + .into_iter() + .find_map(|(window_id, visible_id)| { + (visible_id == Some(session_id.as_str())).then_some(window_id) + }) + { + self.focus_main_window(window_id); + } else { + self.select_session(session_id); + self.focus = PaneFocus::View; + } self.lineage_focused = false; self.set_vim_insert_if_captured(); self.set_status(format!("Creator Micro selected session {}", slot + 1)); @@ -36181,6 +36192,60 @@ mod tests { server.abort(); } + #[tokio::test] + async fn creator_micro_session_key_focuses_visible_session_or_replaces_active_pane() { + let (mut app, _dir, server) = captured_app().await; + let activity_at = chrono::Utc::now(); + for id in ["s2", "s3"] { + let mut session = summary_with_kind(construct_protocol::SessionKind::User); + session.id = id.into(); + session.last_event_at = Some(activity_at); + app.sessions.push(session); + } + if let Some(session) = app.sessions.iter_mut().find(|session| session.id == "s1") { + session.last_event_at = Some(activity_at); + } + app.main_windows = MainWindowTree::Split { + direction: WindowSplitDirection::Right, + ratio_percent: 50, + first: Box::new(MainWindowTree::Leaf { + id: 1, + selection: Selection::Session("s1".into()), + }), + second: Box::new(MainWindowTree::Leaf { + id: 2, + selection: Selection::Session("s2".into()), + }), + }; + app.active_window_id = 1; + app.selection = Selection::Session("s1".into()); + app.creator_micro_session_slots[0] = Some("s2".into()); + app.creator_micro_session_slots[1] = Some("s3".into()); + + app.select_creator_micro_session(0); + assert_eq!(app.active_window_id, 2); + assert_eq!( + app.selection_for_window(1), + Some(Selection::Session("s1".into())) + ); + assert_eq!( + app.selection_for_window(2), + Some(Selection::Session("s2".into())) + ); + + app.select_creator_micro_session(1); + assert_eq!(app.active_window_id, 2); + assert_eq!( + app.selection_for_window(1), + Some(Selection::Session("s1".into())) + ); + assert_eq!( + app.selection_for_window(2), + Some(Selection::Session("s3".into())) + ); + server.abort(); + } + #[tokio::test] async fn focus_pane_index_zero_is_the_list() { let (mut app, _dir, server) = captured_app().await; diff --git a/docs/creator-micro.md b/docs/creator-micro.md index 3f62195b..8fb270cb 100644 --- a/docs/creator-micro.md +++ b/docs/creator-micro.md @@ -47,6 +47,8 @@ user sessions. A session that remains in that set keeps its physical key even when its recency rank changes. When a newly active session enters a full set, it inherits the key vacated by the least-recent session. Subagents, operators, the minibuffer, and sessions with no recorded activity do not take a key. +Pressing a session key focuses its existing split pane when it is already +visible. Otherwise, it opens the session in the currently focused split pane. `ACT10` and `ACT11` are independent yes/no inputs. A stock wide keycap can actuate both switches together; use independently pressable keycaps for this diff --git a/specs/0213-creator-micro-is-a-native-fleet-surface.md b/specs/0213-creator-micro-is-a-native-fleet-surface.md index c122b487..6bc0d9ee 100644 --- a/specs/0213-creator-micro-is-a-native-fleet-surface.md +++ b/specs/0213-creator-micro-is-a-native-fleet-surface.md @@ -17,10 +17,12 @@ in the vacated slot instead of reshuffling every surviving assignment. Each assigned key displays that session's state: dim blue is idle, breathing amber is running, and bright green needs attention. An unassigned key is off. Pressing an agent key selects that session in the active pane and gives its view -keyboard focus. The four middle-row action keys focus split panes 1–4 in their -visible ordinal order. The three bottom-row switches dispatch yes, no, and -enter. The encoder dispatches the same scroll and focus actions as Construct's -keyboard, mouse, and MIDI inputs. +keyboard focus. If that session is already visible in another split pane, the +key focuses that pane without swapping its contents; otherwise it replaces the +session in the currently active pane. The four middle-row action keys focus +split panes 1–4 in their visible ordinal order. The three bottom-row switches +dispatch yes, no, and enter. The encoder dispatches the same scroll and focus +actions as Construct's keyboard, mouse, and MIDI inputs. The integration is disabled until the user opts in. Once enabled, a sleeping or disconnected wireless device is retried without blocking the TUI and has a