From 5cabbe88901f26e6316503d702eba8fb8e1ed1f2 Mon Sep 17 00:00:00 2001 From: Edwin Date: Thu, 10 Sep 2026 08:08:37 -0700 Subject: [PATCH] feat(cli): light the full Creator Micro surface --- crates/cli/src/app.rs | 30 +++- crates/cli/src/creator_micro.rs | 155 ++++++++++++++++-- docs/creator-micro.md | 29 ++-- ...creator-micro-is-a-native-fleet-surface.md | 22 ++- 4 files changed, 204 insertions(+), 32 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 522b43d5..338f5a14 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -7305,6 +7305,16 @@ fn creator_micro_snapshot_for_sessions( snapshot.attention |= bit; } } + for session in sessions + .iter() + .filter(|session| !session.archived && is_user_list_session(session)) + { + snapshot.fleet_active |= matches!( + session.state, + SessionState::Pending | SessionState::Running + ); + snapshot.fleet_attention |= session.needs_attention; + } snapshot } @@ -15209,7 +15219,18 @@ impl App { &self.sessions, &mut self.creator_micro_session_slots, ); - creator_micro_snapshot_for_sessions(&self.sessions, &self.creator_micro_session_slots) + let mut snapshot = + creator_micro_snapshot_for_sessions(&self.sessions, &self.creator_micro_session_slots); + let panes = self.main_windows.leaf_panes(); + snapshot.pane_count = panes.len().min(4) as u8; + if self.focus == PaneFocus::View { + snapshot.focused_pane = panes + .iter() + .position(|(window_id, _)| *window_id == self.active_window_id) + .filter(|pane| *pane < 4) + .map(|pane| pane as u8); + } + snapshot } pub(crate) fn op_xy_feedback_snapshot( @@ -18624,6 +18645,10 @@ mod tests { assigned: 0b0000_0111, active: 0b0000_0010, attention: 0b0000_0100, + pane_count: 0, + focused_pane: None, + fleet_active: true, + fleet_attention: true, } ); } @@ -36182,6 +36207,9 @@ mod tests { app.select_creator_micro_pane(2); assert_eq!(app.focus, PaneFocus::View); assert_eq!(app.active_window_id, 2); + let snapshot = app.creator_micro_snapshot(); + assert_eq!(snapshot.pane_count, 3); + assert_eq!(snapshot.focused_pane, Some(1)); app.select_creator_micro_pane(4); assert_eq!(app.active_window_id, 2); diff --git a/crates/cli/src/creator_micro.rs b/crates/cli/src/creator_micro.rs index 290c4906..05674c5f 100644 --- a/crates/cli/src/creator_micro.rs +++ b/crates/cli/src/creator_micro.rs @@ -31,6 +31,10 @@ const REPORT_SIZE: usize = 64; const MAX_PAYLOAD: usize = 61; const RECONNECT_DELAY: Duration = Duration::from_secs(1); const FEEDBACK_HEARTBEAT: Duration = Duration::from_secs(10); +const LIGHTING_WRITE_COOLDOWN: Duration = Duration::from_millis(50); +const SESSION_KEY_COUNT: usize = 6; +const PANE_KEY_COUNT: usize = 4; +const THREAD_KEY_COUNT: usize = 13; #[derive(Debug, Clone, Subcommand)] pub enum CreatorMicroCommand { @@ -102,9 +106,10 @@ pub fn run(command: Option) -> Result<()> { println!("Open a new Construct TUI to connect."); println!(); println!("The active Work Louder layer must use these Input keycodes:"); - println!(" keys 1-6: KV_OAI_AG00 through KV_OAI_AG05"); - println!(" action row: KV_OAI_ACT06 through KV_OAI_ACT12"); + println!(" all 13 keys: KV_OAI_AG00 through KV_OAI_AG12"); println!(" encoder: KV_OAI_ENC_CC / KV_OAI_ENC_CW / KV_OAI_ENC_CLK"); + println!(" legacy KV_OAI_ACT06 through KV_OAI_ACT12 inputs still work,"); + println!(" but AG06 through AG12 are required for individual lighting."); Ok(()) } CreatorMicroCommand::Disable => { @@ -122,6 +127,13 @@ pub(crate) struct CreatorMicroSnapshot { pub assigned: u8, pub active: u8, pub attention: u8, + /// Number of visible split panes, capped at the four hardware pane keys. + pub pane_count: u8, + /// Zero-based visible pane ordinal when a split pane owns keyboard focus. + pub focused_pane: Option, + /// Aggregate fleet state drives the device underglow. + pub fleet_active: bool, + pub fleet_attention: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -341,27 +353,79 @@ fn send_feedback( snapshot: CreatorMicroSnapshot, request_id: &mut u16, ) -> Result<()> { - let params = (0..6) + let preview = lighting_preview_message(snapshot, *request_id); + *request_id = (*request_id + 1) % 999; + write_rpc(device, &preview).context("write Creator Micro underglow")?; + std::thread::sleep(LIGHTING_WRITE_COOLDOWN); + + let message = thread_status_message(snapshot, *request_id); + *request_id = (*request_id + 1) % 999; + write_rpc(device, &message).context("write Creator Micro key feedback") +} + +fn thread_status_message(snapshot: CreatorMicroSnapshot, request_id: u16) -> Value { + let params = (0..THREAD_KEY_COUNT) .map(|slot| { - let bit = 1 << slot; - if snapshot.attention & bit != 0 { + let session_bit = if slot < SESSION_KEY_COUNT { + 1u8 << slot + } else { + 0 + }; + if slot < SESSION_KEY_COUNT && snapshot.attention & session_bit != 0 { json!({"id": slot, "c": 0x00c853, "b": 1.0, "e": 6, "s": 0.75}) - } else if snapshot.active & bit != 0 { + } else if slot < SESSION_KEY_COUNT && snapshot.active & session_bit != 0 { json!({"id": slot, "c": 0xffc400, "b": 0.9, "e": 4, "s": 0.6}) - } else if snapshot.assigned & bit != 0 { + } else if slot < SESSION_KEY_COUNT && snapshot.assigned & session_bit != 0 { json!({"id": slot, "c": 0x2d7ff9, "b": 0.22, "e": 1, "s": 0.0}) + } else if (SESSION_KEY_COUNT..SESSION_KEY_COUNT + PANE_KEY_COUNT).contains(&slot) { + let pane = (slot - SESSION_KEY_COUNT) as u8; + if snapshot.focused_pane == Some(pane) { + json!({"id": slot, "c": 0x5ce1ff, "b": 1.0, "e": 1, "s": 0.0}) + } else if pane < snapshot.pane_count { + json!({"id": slot, "c": 0x2d7ff9, "b": 0.28, "e": 1, "s": 0.0}) + } else { + json!({"id": slot, "c": 0, "b": 0.0, "e": 0, "s": 0.0}) + } + } else if slot == 10 { + json!({"id": slot, "c": 0x00c853, "b": 0.5, "e": 1, "s": 0.0}) + } else if slot == 11 { + json!({"id": slot, "c": 0xff4d5f, "b": 0.5, "e": 1, "s": 0.0}) + } else if slot == 12 { + json!({"id": slot, "c": 0xffffff, "b": 0.6, "e": 1, "s": 0.0}) } else { json!({"id": slot, "c": 0, "b": 0.0, "e": 0, "s": 0.0}) } }) .collect::>(); - let message = json!({ + json!({ "method": "v.oai.thstatus", "params": params, - "id": *request_id, - }); - *request_id = (*request_id + 1) % 999; - write_rpc(device, &message).context("write Creator Micro feedback") + "id": request_id, + }) +} + +fn lighting_preview_message(snapshot: CreatorMicroSnapshot, request_id: u16) -> Value { + let (effect, brightness, speed, color) = if snapshot.fleet_attention { + ("breath", 0.65, 0.6, 0x00c853) + } else if snapshot.fleet_active { + ("breath", 0.5, 0.6, 0xffc400) + } else { + ("solid", 0.18, 0.0, 0x2d7ff9) + }; + json!({ + "method": "lights.preview", + "params": { + "backlight": { + "effect": "off", "brightness": 0.0, "speed": 0.0, + "magic": 0.0, "color": 0 + }, + "underglow": { + "effect": effect, "brightness": brightness, "speed": speed, + "magic": 0.0, "color": color + } + }, + "id": request_id, + }) } #[cfg(target_os = "macos")] @@ -488,9 +552,15 @@ fn event_from_message(message: &Value) -> Option { if let Some(slot) = key .strip_prefix("AG") .and_then(|slot| slot.parse::().ok()) - .filter(|slot| *slot < 6) { - return Some(CreatorMicroEvent::Session(slot)); + return Some(match slot { + 0..=5 => CreatorMicroEvent::Session(slot), + 6..=9 => CreatorMicroEvent::Pane(slot - 5), + 10 => CreatorMicroEvent::Approve, + 11 => CreatorMicroEvent::Reject, + 12 => CreatorMicroEvent::Enter, + _ => return None, + }); } Some(match key { "ACT06" => CreatorMicroEvent::Pane(1), @@ -580,6 +650,22 @@ mod tests { event_from_message(&event("AG05")), Some(CreatorMicroEvent::Session(5)) ); + assert_eq!( + event_from_message(&event("AG07")), + Some(CreatorMicroEvent::Pane(2)) + ); + assert_eq!( + event_from_message(&event("AG10")), + Some(CreatorMicroEvent::Approve) + ); + assert_eq!( + event_from_message(&event("AG11")), + Some(CreatorMicroEvent::Reject) + ); + assert_eq!( + event_from_message(&event("AG12")), + Some(CreatorMicroEvent::Enter) + ); assert_eq!( event_from_message(&event("ACT07")), Some(CreatorMicroEvent::Pane(2)) @@ -602,6 +688,47 @@ mod tests { ); } + #[test] + fn feedback_lights_all_thirteen_keys_and_aggregate_underglow() { + let snapshot = CreatorMicroSnapshot { + assigned: 0b0000_0001, + active: 0, + attention: 0, + pane_count: 2, + focused_pane: Some(1), + fleet_active: true, + fleet_attention: false, + }; + let thread_status = thread_status_message(snapshot, 7); + let keys = thread_status["params"].as_array().unwrap(); + assert_eq!(thread_status["method"], "v.oai.thstatus"); + assert_eq!(thread_status["id"], 7); + assert_eq!(keys.len(), 13); + assert_eq!(keys[0]["c"], 0x2d7ff9); + assert_eq!(keys[6]["c"], 0x2d7ff9); + assert_eq!(keys[7]["c"], 0x5ce1ff); + assert_eq!(keys[8]["b"], 0.0); + assert_eq!(keys[10]["c"], 0x00c853); + assert_eq!(keys[11]["c"], 0xff4d5f); + assert_eq!(keys[12]["c"], 0xffffff); + + let preview = lighting_preview_message(snapshot, 8); + assert_eq!(preview["method"], "lights.preview"); + assert_eq!(preview["id"], 8); + assert_eq!(preview["params"]["backlight"]["effect"], "off"); + assert_eq!(preview["params"]["underglow"]["effect"], "breath"); + assert_eq!(preview["params"]["underglow"]["color"], 0xffc400); + + let attention = lighting_preview_message( + CreatorMicroSnapshot { + fleet_attention: true, + ..snapshot + }, + 9, + ); + assert_eq!(attention["params"]["underglow"]["color"], 0x00c853); + } + #[test] fn releases_are_suppressed() { let release = json!({"m":"v.oai.hid","p":{"k":"AG00","act":0}}); diff --git a/docs/creator-micro.md b/docs/creator-micro.md index 8fb270cb..469a067d 100644 --- a/docs/creator-micro.md +++ b/docs/creator-micro.md @@ -12,16 +12,18 @@ keycodes: | Physical control | Input keycode | Construct behavior | |---|---|---| -| 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 | +| Six session keys | `KV_OAI_AG00` … `KV_OAI_AG05` | Select six recent sessions in stable hardware slots | +| Middle row | `KV_OAI_AG06` … `KV_OAI_AG09` | Focus split panes 1–4 | +| Bottom-left | `KV_OAI_AG10` | Answer yes | +| Bottom-middle | `KV_OAI_AG11` | Answer no | +| Bottom-right | `KV_OAI_AG12` | 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. -Use a dedicated layer if the controls already hold macros you want to keep. -Construct does not rewrite the device keymap. +The `KV_OAI_AG*` keycodes produce vendor events and let Construct address each +switch LED independently. The older `KV_OAI_ACT06` … `KV_OAI_ACT12` bindings +still perform the same actions, but their LEDs cannot be controlled +individually. Use a dedicated layer if the controls already hold macros you +want to keep. Construct does not rewrite the device keymap. ## Enable Construct @@ -60,9 +62,16 @@ Key colours are: - breathing amber — pending or running - bright green — needs attention - off — no session assigned +- pane keys — dim blue when present, bright cyan when focused, off when absent +- answer keys — green for yes, red for no, and white for enter -The thread colours are device-wide. Disable other software that drives Creator -Micro agent lights while using it with Construct. +The underglow summarizes all top-level user sessions: dim blue while idle, +breathing amber while work is active, and breathing green when any session +needs attention. Construct sends this as a live preview and does not persist it +to the device keymap. + +The thread colours and underglow are device-wide. Disable other software that +drives Creator Micro lighting while using it with Construct. ## Disable or diagnose 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 6bc0d9ee..415f2094 100644 --- a/specs/0213-creator-micro-is-a-native-fleet-surface.md +++ b/specs/0213-creator-micro-is-a-native-fleet-surface.md @@ -19,10 +19,17 @@ 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. 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. +session in the currently active pane. The four middle-row keys focus split +panes 1–4 in their visible ordinal order and show whether each pane exists and +which one is focused. The three bottom-row switches dispatch yes, no, and enter +and use stable semantic colours. The encoder dispatches the same scroll and +focus actions as Construct's keyboard, mouse, and MIDI inputs. + +All thirteen switch positions use Agent keycodes so their LEDs are independently +addressable through thread-status feedback. Construct also previews the general +underglow as an aggregate fleet signal: blue is idle, breathing amber is active, +and breathing green needs attention. Lighting previews are transient and do not +rewrite the saved keymap or its stored lighting configuration. 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 @@ -47,9 +54,10 @@ clients, so the middle row can target them without a second numbering scheme. - Native control currently depends on macOS HID support. - A TUI must be open; the daemon alone does not own the physical surface. -- The active device layer must map its controls to the firmware's vendor agent, - action, and encoder keycodes. Those keycodes emit host events instead of - ordinary keystrokes. +- The active device layer must map all thirteen switches to the firmware's + `AG00` through `AG12` keycodes for independent lighting. Legacy action + keycodes remain accepted for input compatibility but do not expose their + switches to thread-status lighting. - 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