From 7732de6900348306eaaf21d0b2d40844687c2ede Mon Sep 17 00:00:00 2001 From: Edwin Date: Wed, 9 Sep 2026 20:17:18 -0700 Subject: [PATCH] feat: add Creator Micro 2 control surface --- Cargo.lock | 14 + Cargo.toml | 1 + README.md | 3 + crates/cli/Cargo.toml | 1 + crates/cli/src/app.rs | 171 ++++++ crates/cli/src/creator_micro.rs | 536 ++++++++++++++++++ crates/cli/src/main.rs | 7 + crates/cli/src/ui.rs | 17 + crates/protocol/src/paths.rs | 4 + docs/creator-micro.md | 70 +++ ...creator-micro-is-a-native-fleet-surface.md | 64 +++ 11 files changed, 888 insertions(+) create mode 100644 crates/cli/src/creator_micro.rs create mode 100644 docs/creator-micro.md create mode 100644 specs/0213-creator-micro-is-a-native-fleet-surface.md diff --git a/Cargo.lock b/Cargo.lock index ad0663f5..695cc4e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -785,6 +785,7 @@ dependencies = [ "diffy", "eventsource-stream", "futures", + "hidapi", "image", "libc", "midir", @@ -1895,6 +1896,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "hidapi" +version = "2.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6" +dependencies = [ + "cc", + "cfg-if", + "libc", + "pkg-config", + "windows-sys 0.61.2", +] + [[package]] name = "hkdf" version = "0.12.4" diff --git a/Cargo.toml b/Cargo.toml index e67c250f..8c0b6d62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] diffy = "0.4" rusqlite = { version = "0.32", features = ["bundled"] } midir = "0.11" +hidapi = "2.6" construct-protocol = { path = "crates/protocol" } construct-client = { path = "crates/client" } diff --git a/README.md b/README.md index ca2863dc..9da83576 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,9 @@ Use `?` for help and `M-x` for the command palette. From the TUI you can create sessions, switch between agents, send input, inspect diffs, and interrupt or stop work without leaving the flow. +On macOS, a [Work Louder Creator Micro 2](docs/creator-micro.md) can select six +live sessions and mirror their idle, working, and attention state on its keys. + You can also launch straight into construct by prepending `construct new` to your favorite CLI harness command: diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index bd57216b..9495ed3a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -65,3 +65,4 @@ libc = "0.2" # release/cross builds do not acquire a system ALSA linkage requirement. [target.'cfg(target_os = "macos")'.dependencies] midir = { workspace = true } +hidapi = { workspace = true, features = ["macos-shared-device"] } diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 6dbe9458..655520c7 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -2647,6 +2647,9 @@ pub struct App { /// the feedback loop reports (or when no OP-XY profile is enabled — the /// indicator only renders once a report arrives), then `Some(connected)`. pub op_xy_link_connected: Option, + /// 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, /// 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 @@ -5940,6 +5943,7 @@ async fn run_with_socket_initial_selection( image_resize_cache: Vec::new(), session_transitions: HashMap::new(), op_xy_link_connected: None, + creator_micro_link_connected: None, matrix_rain: crate::matrix_rain::MatrixRain::default(), matrix_rain_intensity: 0.0, matrix_rain_intensity_updated_at: now, @@ -6208,6 +6212,17 @@ async fn run_loop( (None, None) } }; + let (creator_micro, mut creator_micro_rx, mut creator_micro_link_rx) = + match crate::creator_micro::start_surface() { + Ok(Some((surface, event_rx, link_rx))) => { + (Some(surface), Some(event_rx), Some(link_rx)) + } + Ok(None) => (None, None, None), + Err(e) => { + app.set_status(format!("Creator Micro disabled: {e}")); + (None, None, None) + } + }; let mut notifications = app .client .take_notifications() @@ -6697,6 +6712,44 @@ async fn run_loop( None => midi_link_rx = None, } } + event = async { + match creator_micro_rx.as_mut() { + Some(rx) => rx.recv().await, + None => futures::future::pending().await, + } + }, if creator_micro_rx.is_some() => { + match event { + Some(crate::creator_micro::CreatorMicroEvent::Session(slot)) => { + app.select_creator_micro_session(slot); + } + Some(crate::creator_micro::CreatorMicroEvent::Enter) => { + app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).await; + } + Some(crate::creator_micro::CreatorMicroEvent::Approve) => { + app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)).await; + } + Some(crate::creator_micro::CreatorMicroEvent::Reject) => { + app.on_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)).await; + } + Some(crate::creator_micro::CreatorMicroEvent::Action(action)) => { + if let Some(key_action) = action.key_action() { + app.run_action(key_action).await; + } + } + None => creator_micro_rx = None, + } + } + status = async { + match creator_micro_link_rx.as_mut() { + Some(rx) => rx.recv().await, + None => futures::future::pending().await, + } + }, if creator_micro_link_rx.is_some() => { + match status { + Some(status) => app.apply_creator_micro_link_status(status), + None => creator_micro_link_rx = None, + } + } hydrated = hydration_tasks.join_next(), if !hydration_tasks.is_empty() => { match hydrated { Some(Ok((id, Ok(h)))) => { @@ -7044,6 +7097,9 @@ async fn run_loop( if let Some(feedback) = midi_feedback.as_ref() { feedback.update(app.op_xy_feedback_snapshot(feedback.aggregate_scope())); } + if let Some(surface) = creator_micro.as_ref() { + surface.update(app.creator_micro_snapshot()); + } } Ok(()) } @@ -7168,6 +7224,37 @@ fn op_xy_slot_state_masks(sessions: &[SessionSummary], slots: &[Option]) }) } +fn creator_micro_session_slots(sessions: &[SessionSummary]) -> Vec { + sessions + .iter() + .filter(|session| !session.archived && is_user_list_session(session)) + .take(6) + .map(|session| session.id.clone()) + .collect() +} + +fn creator_micro_snapshot_for_sessions( + sessions: &[SessionSummary], +) -> 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 bit = 1 << slot; + snapshot.assigned |= bit; + let Some(session) = sessions.iter().find(|session| session.id == *session_id) else { + continue; + }; + if matches!(session.state, SessionState::Pending | SessionState::Running) { + snapshot.active |= bit; + } + if session.needs_attention { + snapshot.attention |= bit; + } + } + snapshot +} + impl App { pub(crate) fn focused_border_target(&self) -> FocusBorderTarget { match self.focus { @@ -15002,6 +15089,46 @@ impl App { } } + pub(crate) fn apply_creator_micro_link_status( + &mut self, + status: crate::creator_micro::CreatorMicroLinkStatus, + ) { + let connected = matches!( + status, + crate::creator_micro::CreatorMicroLinkStatus::Connected { .. } + ); + if self.creator_micro_link_connected == Some(connected) { + return; + } + self.creator_micro_link_connected = Some(connected); + match status { + crate::creator_micro::CreatorMicroLinkStatus::Connected { product, transport } => { + self.set_status(format!("Creator Micro connected: {product} over {transport}")); + } + crate::creator_micro::CreatorMicroLinkStatus::Disconnected => { + self.set_status("Creator Micro disconnected: waiting for the device".into()); + } + } + } + + 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 { + self.set_status(format!("Creator Micro session key {} is unassigned", slot + 1)); + return; + }; + 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)); + } + + pub(crate) fn creator_micro_snapshot( + &self, + ) -> crate::creator_micro::CreatorMicroSnapshot { + creator_micro_snapshot_for_sessions(&self.sessions) + } + pub(crate) fn op_xy_feedback_snapshot( &self, aggregate_scope: crate::midi::OpXyAggregateScope, @@ -18133,6 +18260,7 @@ mod tests { image_resize_cache: Vec::new(), session_transitions: HashMap::new(), op_xy_link_connected: None, + creator_micro_link_connected: None, matrix_rain: crate::matrix_rain::MatrixRain::default(), matrix_rain_intensity: 0.0, matrix_rain_intensity_updated_at: now, @@ -18341,6 +18469,49 @@ mod tests { } } + #[test] + fn creator_micro_slots_follow_live_user_list_order() { + let mut sessions = (0..8) + .map(|index| { + let mut session = summary_with_kind(construct_protocol::SessionKind::User); + session.id = format!("s{index}"); + session + }) + .collect::>(); + sessions[1].kind = construct_protocol::SessionKind::Subagent; + sessions[2].archived = true; + + assert_eq!( + creator_micro_session_slots(&sessions), + vec!["s0", "s3", "s4", "s5", "s6", "s7"] + ); + } + + #[test] + fn creator_micro_snapshot_encodes_assignment_activity_and_attention() { + let mut idle = summary_with_kind(construct_protocol::SessionKind::User); + idle.id = "idle".into(); + idle.state = construct_protocol::SessionState::Done; + + let mut active = summary_with_kind(construct_protocol::SessionKind::User); + active.id = "active".into(); + active.state = construct_protocol::SessionState::Running; + + let mut attention = summary_with_kind(construct_protocol::SessionKind::User); + attention.id = "attention".into(); + attention.state = construct_protocol::SessionState::Done; + attention.needs_attention = true; + + assert_eq!( + creator_micro_snapshot_for_sessions(&[idle, active, attention]), + crate::creator_micro::CreatorMicroSnapshot { + assigned: 0b0000_0111, + active: 0b0000_0010, + attention: 0b0000_0100, + } + ); + } + #[test] fn op_xy_title_slots_prefer_the_most_recent_activity() { let base = chrono::Utc::now(); diff --git a/crates/cli/src/creator_micro.rs b/crates/cli/src/creator_micro.rs new file mode 100644 index 00000000..e54cb385 --- /dev/null +++ b/crates/cli/src/creator_micro.rs @@ -0,0 +1,536 @@ +//! Native Work Louder Creator Micro 2 control-surface support. +//! +//! The device carries a small JSON-RPC server on HID report 6. Construct opens +//! that report pipe non-exclusively, so the board remains a normal keyboard and +//! works over either USB or Bluetooth. The integration is opt-in through +//! `creator-micro.toml`; this avoids competing with Work Louder Input or another +//! agent-status host for the device-wide thread LEDs. + +use std::cell::Cell; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc as std_mpsc}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use clap::Subcommand; +use construct_protocol::paths::Paths; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::sync::mpsc; + +use crate::midi::MidiAction; + +const VENDOR_ID: u16 = 0x303a; +const PRODUCT_IDS: [u16; 3] = [0x8298, 0x8297, 0x8360]; +const VENDOR_USAGE_PAGE: u16 = 0xff00; +const VENDOR_USAGE: u16 = 0x0001; +const REPORT_ID: u8 = 0x06; +const CHANNEL_RPC: u8 = 2; +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); + +#[derive(Debug, Clone, Subcommand)] +pub enum CreatorMicroCommand { + /// Show whether native Creator Micro control is enabled and reachable. + Status, + /// List connected compatible Work Louder devices. + Devices, + /// Enable the control surface for subsequently opened TUIs. + Enable, + /// Stop Construct from opening or lighting the control surface. + Disable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +struct CreatorMicroConfig { + enabled: bool, +} + +impl Default for CreatorMicroConfig { + fn default() -> Self { + Self { enabled: false } + } +} + +impl CreatorMicroConfig { + fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(raw) => toml::from_str(&raw).with_context(|| format!("parse {}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(error).with_context(|| format!("read {}", path.display())), + } + } + + fn save(&self, path: &Path) -> Result<()> { + let parent = path + .parent() + .context("Creator Micro config path has no parent directory")?; + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let raw = toml::to_string_pretty(self).context("serialize Creator Micro config")?; + let mut temp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("create temporary file in {}", parent.display()))?; + use std::io::Write as _; + temp.write_all(raw.as_bytes())?; + temp.as_file().sync_all()?; + temp.persist(path) + .map_err(|error| error.error) + .with_context(|| format!("replace {}", path.display()))?; + Ok(()) + } +} + +pub fn run(command: Option) -> Result<()> { + let path = Paths::discover().creator_micro_file(); + match command.unwrap_or(CreatorMicroCommand::Status) { + CreatorMicroCommand::Status => { + let config = CreatorMicroConfig::load(&path)?; + println!("config: {}", path.display()); + println!("enabled: {}", config.enabled); + print_devices() + } + CreatorMicroCommand::Devices => print_devices(), + CreatorMicroCommand::Enable => { + CreatorMicroConfig { enabled: true }.save(&path)?; + println!("Creator Micro control enabled in {}", path.display()); + 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!(" encoder: KV_OAI_ENC_CC / KV_OAI_ENC_CW / KV_OAI_ENC_CLK"); + Ok(()) + } + CreatorMicroCommand::Disable => { + CreatorMicroConfig { enabled: false }.save(&path)?; + println!("Creator Micro control disabled in {}", path.display()); + println!("A currently open TUI releases it when that TUI exits."); + Ok(()) + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct CreatorMicroSnapshot { + /// Low six bits say which physical session keys currently have a session. + pub assigned: u8, + pub active: u8, + pub attention: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CreatorMicroEvent { + Session(usize), + Enter, + Approve, + Reject, + Action(MidiAction), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CreatorMicroLinkStatus { + Connected { product: String, transport: String }, + Disconnected, +} + +pub(crate) struct CreatorMicroSurface { + feedback_tx: std_mpsc::Sender, + last_snapshot: Cell, + stop: Arc, +} + +impl CreatorMicroSurface { + pub(crate) fn update(&self, snapshot: CreatorMicroSnapshot) { + if self.last_snapshot.replace(snapshot) != snapshot { + let _ = self.feedback_tx.send(snapshot); + } + } +} + +impl Drop for CreatorMicroSurface { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +type EventReceiver = mpsc::UnboundedReceiver; +type LinkReceiver = mpsc::UnboundedReceiver; + +pub(crate) fn start_surface() -> Result> +{ + let config = CreatorMicroConfig::load(&Paths::discover().creator_micro_file())?; + if !config.enabled { + return Ok(None); + } + start_surface_platform() +} + +#[cfg(target_os = "macos")] +fn start_surface_platform() -> Result> { + let (feedback_tx, feedback_rx) = std_mpsc::channel(); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (link_tx, link_rx) = mpsc::unbounded_channel(); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = Arc::clone(&stop); + std::thread::Builder::new() + .name("construct-creator-micro".into()) + .spawn(move || worker_loop(feedback_rx, event_tx, link_tx, worker_stop)) + .context("spawn Creator Micro thread")?; + Ok(Some(( + CreatorMicroSurface { + feedback_tx, + last_snapshot: Cell::new(CreatorMicroSnapshot::default()), + stop, + }, + event_rx, + link_rx, + ))) +} + +#[cfg(not(target_os = "macos"))] +fn start_surface_platform() -> Result> { + anyhow::bail!("native Creator Micro control is currently supported on macOS") +} + +#[cfg(target_os = "macos")] +fn worker_loop( + feedback_rx: std_mpsc::Receiver, + event_tx: mpsc::UnboundedSender, + link_tx: mpsc::UnboundedSender, + stop: Arc, +) { + let mut snapshot = CreatorMicroSnapshot::default(); + while !stop.load(Ordering::Relaxed) { + while let Ok(next) = feedback_rx.try_recv() { + snapshot = next; + } + match run_connection(&feedback_rx, &event_tx, &link_tx, &stop, &mut snapshot) { + Ok(()) => break, + Err(error) => tracing::debug!(%error, "Creator Micro disconnected"), + } + // Emit the initial waiting state too, so an enabled but sleeping + // wireless board has an honest hollow indicator in the modeline. + let _ = link_tx.send(CreatorMicroLinkStatus::Disconnected); + let deadline = Instant::now() + RECONNECT_DELAY; + while Instant::now() < deadline && !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +#[cfg(target_os = "macos")] +fn run_connection( + feedback_rx: &std_mpsc::Receiver, + event_tx: &mpsc::UnboundedSender, + link_tx: &mpsc::UnboundedSender, + stop: &AtomicBool, + snapshot: &mut CreatorMicroSnapshot, +) -> Result<()> { + use hidapi::HidApi; + + let api = HidApi::new().context("initialize HID")?; + let mut candidates = api + .device_list() + .filter(|device| { + device.vendor_id() == VENDOR_ID && PRODUCT_IDS.contains(&device.product_id()) + }) + .collect::>(); + candidates.sort_by_key(|device| { + let vendor_collection = + device.usage_page() == VENDOR_USAGE_PAGE && device.usage() == VENDOR_USAGE; + let wired = matches!(device.bus_type(), hidapi::BusType::Usb); + ( + !vendor_collection, + !wired, + device.path().to_bytes().to_vec(), + ) + }); + let info = candidates + .first() + .context("no Creator Micro 2 found; wake it or connect USB-C")?; + let device = info + .open_device(&api) + .context("open Creator Micro 2 non-exclusively")?; + let product = info + .product_string() + .unwrap_or("Creator Micro 2") + .to_string(); + let transport = match info.bus_type() { + hidapi::BusType::Usb => "USB", + hidapi::BusType::Bluetooth => "Bluetooth", + hidapi::BusType::I2c => "I2C", + hidapi::BusType::Spi => "SPI", + hidapi::BusType::Unknown => "unknown", + } + .to_string(); + let _ = link_tx.send(CreatorMicroLinkStatus::Connected { product, transport }); + + let mut request_id = 1u16; + 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) { + let mut changed = false; + while let Ok(next) = feedback_rx.try_recv() { + if *snapshot != next { + *snapshot = next; + changed = true; + } + } + if changed || last_feedback.elapsed() >= FEEDBACK_HEARTBEAT { + send_feedback(&device, *snapshot, &mut request_id)?; + last_feedback = Instant::now(); + } + + let count = device + .read_timeout(&mut buffer, 100) + .context("read Creator Micro report")?; + if count < 3 || buffer[0] != REPORT_ID || buffer[1] != CHANNEL_RPC { + continue; + } + let payload_len = usize::from(buffer[2]); + if payload_len > MAX_PAYLOAD || 3 + payload_len > count { + continue; + } + for message in reassembler.push(&buffer[3..3 + payload_len]) { + if let Some(event) = event_from_message(&message, &mut wide_pressed_at) { + let _ = event_tx.send(event); + } + } + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn send_feedback( + device: &hidapi::HidDevice, + snapshot: CreatorMicroSnapshot, + request_id: &mut u16, +) -> Result<()> { + let params = (0..6) + .map(|slot| { + let bit = 1 << slot; + if snapshot.attention & bit != 0 { + json!({"id": slot, "c": 0x00c853, "b": 1.0, "e": 6, "s": 0.75}) + } else if snapshot.active & bit != 0 { + json!({"id": slot, "c": 0xffc400, "b": 0.9, "e": 4, "s": 0.6}) + } else if snapshot.assigned & bit != 0 { + json!({"id": slot, "c": 0x2d7ff9, "b": 0.22, "e": 1, "s": 0.0}) + } else { + json!({"id": slot, "c": 0, "b": 0.0, "e": 0, "s": 0.0}) + } + }) + .collect::>(); + let message = serde_json::to_vec(&json!({ + "method": "v.oai.thstatus", + "params": params, + "id": *request_id, + }))?; + *request_id = (*request_id + 1) % 999; + + let framed = [b"\r\n".as_slice(), message.as_slice(), b"\r\n".as_slice()].concat(); + for chunk in framed.chunks(MAX_PAYLOAD) { + let mut packet = [0u8; REPORT_SIZE]; + packet[0] = REPORT_ID; + packet[1] = CHANNEL_RPC; + packet[2] = chunk.len() as u8; + packet[3..3 + chunk.len()].copy_from_slice(chunk); + device + .write(&packet) + .context("write Creator Micro feedback")?; + } + Ok(()) +} + +#[derive(Default)] +struct JsonReassembler { + bytes: Vec, +} + +impl JsonReassembler { + fn push(&mut self, fragment: &[u8]) -> Vec { + self.bytes.extend_from_slice(fragment); + let mut messages = Vec::new(); + while let Some(end) = self + .bytes + .iter() + .position(|byte| matches!(byte, b'\r' | b'\n')) + { + let line = self.bytes.drain(..end).collect::>(); + self.bytes.remove(0); + if let Ok(value) = serde_json::from_slice::(&line) { + messages.push(value); + } + } + // Accept an unframed complete message as well. This is useful for + // older firmware and keeps the parser tolerant of missing final CRLF. + if let Ok(value) = serde_json::from_slice::(&self.bytes) { + messages.push(value); + self.bytes.clear(); + } else if self.bytes.len() > 64 * 1024 { + self.bytes.clear(); + } + messages + } +} + +fn event_from_message( + message: &Value, + wide_pressed_at: &mut Option, +) -> Option { + if message.get("m")?.as_str()? != "v.oai.hid" { + return None; + } + let params = message.get("p")?; + let key = params.get("k")?.as_str()?; + let action = params.get("act")?.as_u64()?; + if action == 0 { + return None; + } + if let Some(slot) = key + .strip_prefix("AG") + .and_then(|slot| slot.parse::().ok()) + .filter(|slot| *slot < 6) + { + 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), + "ENC_CC" => CreatorMicroEvent::Action(MidiAction::ScrollUp), + "ENC_CW" => CreatorMicroEvent::Action(MidiAction::ScrollDown), + "ENC_CLK" => CreatorMicroEvent::Action(MidiAction::SwitchFocus), + _ => return None, + }) +} + +#[cfg(target_os = "macos")] +fn print_devices() -> Result<()> { + let api = hidapi::HidApi::new().context("initialize HID")?; + let devices = api + .device_list() + .filter(|device| { + device.vendor_id() == VENDOR_ID && PRODUCT_IDS.contains(&device.product_id()) + }) + .collect::>(); + if devices.is_empty() { + println!("(no Creator Micro found; wake it or connect USB-C)"); + return Ok(()); + } + for device in devices { + let transport = match device.bus_type() { + hidapi::BusType::Usb => "USB", + hidapi::BusType::Bluetooth => "Bluetooth", + hidapi::BusType::I2c => "I2C", + hidapi::BusType::Spi => "SPI", + hidapi::BusType::Unknown => "unknown", + }; + println!( + "{} ({:04x}:{:04x}, {transport}, usage {:04x}/{:04x})", + device.product_string().unwrap_or("Creator Micro"), + device.vendor_id(), + device.product_id(), + device.usage_page(), + device.usage(), + ); + } + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn print_devices() -> Result<()> { + anyhow::bail!("native Creator Micro control is currently supported on macOS") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fragmented_json_is_reassembled() { + let mut reassembler = JsonReassembler::default(); + assert!(reassembler.push(b"\r\n{\"m\":\"v.oai").is_empty()); + assert_eq!( + reassembler.push(b".hid\",\"p\":{\"k\":\"AG00\",\"act\":1}}\r\n"), + vec![json!({"m":"v.oai.hid","p":{"k":"AG00","act":1}})] + ); + } + + #[test] + fn multiple_framed_messages_are_reassembled() { + let mut reassembler = JsonReassembler::default(); + assert_eq!( + reassembler.push( + b"{\"id\":1,\"result\":{\"ok\":1}}\r\n{\"m\":\"v.oai.hid\",\"p\":{\"k\":\"ACT07\",\"act\":1}}\r\n" + ), + vec![ + json!({"id":1,"result":{"ok":1}}), + json!({"m":"v.oai.hid","p":{"k":"ACT07","act":1}}), + ] + ); + } + + #[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), + Some(CreatorMicroEvent::Session(5)) + ); + assert_eq!( + event_from_message(&event("ACT07"), &mut wide), + Some(CreatorMicroEvent::Approve) + ); + assert_eq!( + event_from_message(&event("ENC_CW"), &mut wide), + Some(CreatorMicroEvent::Action(MidiAction::ScrollDown)) + ); + } + + #[test] + fn releases_and_second_wide_switch_are_suppressed() { + let mut wide = None; + 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); + } + + #[test] + fn config_defaults_disabled_and_round_trips() { + assert!(!toml::from_str::("").unwrap().enabled); + let enabled = CreatorMicroConfig { enabled: true }; + let encoded = toml::to_string(&enabled).unwrap(); + assert_eq!( + toml::from_str::(&encoded).unwrap(), + enabled + ); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index da31d109..47f5f28d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -10,6 +10,7 @@ mod app; mod clipboard_bridge; mod doctor; mod color; +mod creator_micro; mod keymap; mod lineage; mod matrix_rain; @@ -84,6 +85,11 @@ enum Command { #[command(subcommand)] command: Option, }, + /// Configure a Work Louder Creator Micro 2 control surface. + CreatorMicro { + #[command(subcommand)] + command: Option, + }, /// Search session names, playbook contents, and transcript history. Search { query: String, @@ -546,6 +552,7 @@ async fn main() -> Result<()> { Ok(()) } Command::Midi { command } => midi::run(command).await, + Command::CreatorMicro { command } => creator_micro::run(command), Command::Search { query, limit, diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index d3c35251..140140e4 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -13098,6 +13098,9 @@ fn render_modeline(f: &mut Frame, area: Rect, app: &mut App) { if let Some(connected) = app.op_xy_link_connected { persistent_notices.push(vec![(modeline_midi_text(connected), None)]); } + if let Some(connected) = app.creator_micro_link_connected { + persistent_notices.push(vec![(modeline_creator_micro_text(connected), None)]); + } // Ambient-feature degradation notice (spec 0151): shown only when the // daemon has actually skipped auto-naming/suggestions for lack of a // smith credential this run — a credential-less machine that never hit @@ -13464,6 +13467,14 @@ fn modeline_midi_text(connected: bool) -> String { } } +fn modeline_creator_micro_text(connected: bool) -> String { + if connected { + "● micro".to_string() + } else { + "○ micro".to_string() + } +} + fn approval_mode_modeline_label(s: &SessionSummary) -> Option<&'static str> { s.approval_mode .badge() @@ -29513,6 +29524,12 @@ mod tests { assert_eq!(modeline_midi_text(false), "○ midi"); } + #[test] + fn modeline_creator_micro_text_mirrors_the_remote_dot_vocabulary() { + assert_eq!(modeline_creator_micro_text(true), "● micro"); + assert_eq!(modeline_creator_micro_text(false), "○ micro"); + } + #[test] fn matrix_rain_intensity_ramps_up_faster_than_down() { assert_eq!( diff --git a/crates/protocol/src/paths.rs b/crates/protocol/src/paths.rs index 2ca2182e..c442ac3b 100644 --- a/crates/protocol/src/paths.rs +++ b/crates/protocol/src/paths.rs @@ -122,6 +122,10 @@ impl Paths { self.config_dir.join("midi.toml") } + pub fn creator_micro_file(&self) -> PathBuf { + self.config_dir.join("creator-micro.toml") + } + pub fn sessions_root(&self) -> PathBuf { self.data_dir.join("sessions") } diff --git a/docs/creator-micro.md b/docs/creator-micro.md new file mode 100644 index 00000000..9e89c645 --- /dev/null +++ b/docs/creator-micro.md @@ -0,0 +1,70 @@ +# Work Louder Creator Micro 2 + +On macOS, a Creator Micro 2 can be a native Construct fleet controller over +USB-C or Bluetooth. Construct receives controls through the board's vendor HID +channel, so the terminal does not need desktop focus and no Accessibility or +Input Monitoring permission is required. + +## Set up a layer + +In Work Louder Input, dedicate one layer to Construct and assign these vendor +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 | +| 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. + +## Enable Construct + +Wake the board or connect a USB-C data cable, then confirm macOS can see it: + +```sh +construct creator-micro devices +``` + +Enable the integration and open a new TUI: + +```sh +construct creator-micro enable +construct +``` + +`● micro` in the modeline means the vendor channel is connected. `○ micro` +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. + +Key colours are: + +- dim blue — assigned and idle +- breathing amber — pending or running +- bright green — needs attention +- off — no session assigned + +The thread colours are device-wide. Disable other software that drives Creator +Micro agent lights while using it with Construct. + +## Disable or diagnose + +```sh +construct creator-micro status +construct creator-micro disable +``` + +Configuration lives in `creator-micro.toml` under the config directory printed +by `construct paths`. Disabling takes effect for newly opened TUIs; an existing +TUI releases the device when it exits. diff --git a/specs/0213-creator-micro-is-a-native-fleet-surface.md b/specs/0213-creator-micro-is-a-native-fleet-surface.md new file mode 100644 index 00000000..aa22030d --- /dev/null +++ b/specs/0213-creator-micro-is-a-native-fleet-surface.md @@ -0,0 +1,64 @@ +# 0213-creator-micro-is-a-native-fleet-surface + +Status: accepted +Date: 2026-09-09 +Area: tui +Scope: Work Louder Creator Micro 2 devices control and display one live Construct TUI through their vendor HID channel. + +## Decision + +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. + +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. + +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 +visible disconnected state. Feedback is periodically reasserted so reconnects +and dropped wireless updates self-heal. + +## Reason + +The device exposes direct key notifications and per-key lighting that can make +fleet state glanceable without keeping a terminal focused. Using the vendor +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. + +## Consequences + +- 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. +- Archived sessions, subagents, operators, and the minibuffer do not consume one + of the six fleet keys. +- 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. +- Disabling the feature prevents subsequent TUIs from opening or lighting the + device; an already-open TUI releases it when it exits. + +## Non-Goals + +- Global desktop shortcuts when Construct is closed. +- Firmware flashing, factory reset, or automatic device-keymap replacement. +- Treating the Creator Micro as MIDI. + +## 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. +- A sleeping Bluetooth device shows a hollow `micro` indicator; waking it + reconnects and restores all six current states.