Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
}
);
}
Expand Down Expand Up @@ -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);
Expand Down
155 changes: 141 additions & 14 deletions crates/cli/src/creator_micro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -102,9 +106,10 @@ pub fn run(command: Option<CreatorMicroCommand>) -> 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 => {
Expand All @@ -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<u8>,
/// Aggregate fleet state drives the device underglow.
pub fleet_active: bool,
pub fleet_attention: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -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::<Vec<_>>();
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")]
Expand Down Expand Up @@ -488,9 +552,15 @@ fn event_from_message(message: &Value) -> Option<CreatorMicroEvent> {
if let Some(slot) = key
.strip_prefix("AG")
.and_then(|slot| slot.parse::<usize>().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),
Expand Down Expand Up @@ -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))
Expand All @@ -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}});
Expand Down
29 changes: 19 additions & 10 deletions docs/creator-micro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
22 changes: 15 additions & 7 deletions specs/0213-creator-micro-is-a-native-fleet-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading