-
-
- {/* Légende des couleurs d'état poussées sur les six touches Agent par
- `npm run lighting -- watch`. Les teintes viennent de la source unique
- partagée avec l'outillage Node, jamais réécrites ici.
-
- Hors du flux, en bas à gauche : la coque ne défile pas et la colonne
- centrale n'a plus un pixel avant la ligne de flottaison. Repliée par
- défaut, elle s'ouvre vers le haut. */}
-
- {t("stateLegend.title")}
-
+ >
+ );
+}
diff --git a/prototype/src/configurator-catalog.jsx b/prototype/src/configurator-catalog.jsx
new file mode 100644
index 0000000..e2ac04f
--- /dev/null
+++ b/prototype/src/configurator-catalog.jsx
@@ -0,0 +1,339 @@
+import {
+ ArrowDownUp,
+ ArrowLeft,
+ ArrowRight,
+ ChevronsLeft,
+ ChevronsRight,
+ Command,
+ CopyPlus,
+ Diff,
+ Gauge,
+ Mic,
+ Minus,
+ Monitor,
+ MousePointer2,
+ Move,
+ RotateCw,
+ Search,
+ Settings,
+ SlidersHorizontal,
+ Square,
+ Volume2,
+ X,
+ ZoomIn,
+ ZoomOut,
+} from "lucide-react";
+import { FINAL_KEYCODES } from "../../shared/input-profile.mjs";
+import { assertKeyActionCatalog } from "./configurator-state.js";
+
+export const GUIDE_URL =
+ "https://github.com/thannous/claude-codex-micro/blob/main/docs/installation.md";
+export const INPUT_RELEASES_URL =
+ "https://github.com/worklouder/input-releases/releases";
+
+export function ClaudeMarkIcon({ size = 19, className = "" }) {
+ return (
+
+ );
+}
+
+export const CONTROLS = Object.freeze([
+ {
+ id: "wheel",
+ // The control's identity, not a mode: the wheel is no longer on scroll by
+ // default. Shown only when no action is assigned.
+ shortLabel: "DIAL",
+ type: "dial",
+ x: 9.36,
+ y: 9.48,
+ w: 19.6,
+ h: 19.95,
+ },
+ {
+ id: "key-13",
+ shortLabel: "PRESS",
+ type: "key",
+ className: "key-hotspot--encoder-button",
+ x: 13.93,
+ y: 14.17,
+ w: 10.61,
+ h: 10.56,
+ },
+ {
+ id: "key-9",
+ shortLabel: "A1",
+ type: "key",
+ x: 29.84,
+ y: 9.92,
+ w: 20.19,
+ h: 18.63,
+ },
+ {
+ id: "key-10",
+ shortLabel: "A2",
+ type: "key",
+ x: 50.77,
+ y: 9.92,
+ w: 20.19,
+ h: 18.63,
+ },
+ {
+ id: "joystick",
+ shortLabel: "NAV",
+ type: "joystick",
+ x: 73.31,
+ y: 10.06,
+ w: 17.98,
+ h: 19.36,
+ },
+ { id: "key-5", shortLabel: "A3", type: "key", x: 8.77, y: 31.18, w: 20.19, h: 18.63 },
+ { id: "key-6", shortLabel: "A4", type: "key", x: 28.81, y: 31.18, w: 20.19, h: 18.63 },
+ { id: "key-7", shortLabel: "A5", type: "key", x: 51.06, y: 31.18, w: 20.19, h: 18.63 },
+ { id: "key-8", shortLabel: "A6", type: "key", x: 72.13, y: 31.18, w: 20.19, h: 18.63 },
+ { id: "key-1", shortLabel: "C1", type: "key", x: 8.77, y: 53.04, w: 20.19, h: 18.63 },
+ { id: "key-2", shortLabel: "C2", type: "key", x: 28.81, y: 53.04, w: 20.19, h: 18.63 },
+ { id: "key-3", shortLabel: "C3", type: "key", x: 51.06, y: 53.04, w: 20.19, h: 18.63 },
+ { id: "key-4", shortLabel: "C4", type: "key", x: 72.13, y: 53.04, w: 20.19, h: 18.63 },
+ { id: "key-11", shortLabel: "C5", type: "key", x: 28.81, y: 74.3, w: 42.44, h: 18.63 },
+ { id: "key-12", shortLabel: "C6", type: "key", x: 72.13, y: 74.3, w: 20.19, h: 18.63 },
+].map(Object.freeze));
+
+export const CONTROL_BY_ID = new Map(
+ CONTROLS.map((control) => [control.id, control]),
+);
+
+export const RESERVED_ZONES = Object.freeze([
+ Object.freeze({ id: "sensor", x: 8.77, y: 74.3, w: 20.19, h: 18.63, round: true }),
+]);
+
+export const KEYCAP_TONES = Object.freeze({
+ "key-9": "186 235 211",
+ "key-10": "250 218 166",
+ "key-5": "205 187 244",
+ "key-6": "215 216 240",
+ "key-7": "187 201 241",
+ "key-8": "242 181 213",
+ "key-1": "235 233 230",
+ "key-2": "235 233 230",
+ "key-3": "231 232 236",
+ "key-4": "235 233 230",
+ "key-11": "235 233 230",
+ "key-12": "235 233 230",
+ "key-13": "38 36 34",
+});
+
+const actions = {
+ navigation: {
+ id: "navigation",
+ shortcut: "↑ → ↓ ←",
+ icon: Move,
+ controlTypes: ["joystick"],
+ exportLabel: "NAV",
+ },
+ scroll: {
+ id: "scroll",
+ shortcut: "Page ↑ Page ↓",
+ icon: MousePointer2,
+ controlTypes: ["dial"],
+ exportLabel: "SCROLL",
+ },
+ lines: {
+ id: "lines",
+ shortcut: "↑ ↓",
+ icon: ArrowDownUp,
+ controlTypes: ["dial"],
+ exportLabel: "LINES",
+ },
+ effort: {
+ id: "effort",
+ shortcut: "⌘ ⇧ E · ← / →",
+ icon: SlidersHorizontal,
+ controlTypes: ["dial"],
+ exportLabel: "EFFORT",
+ },
+ volume: {
+ id: "volume",
+ shortcut: "Vol − +",
+ icon: Volume2,
+ controlTypes: ["dial"],
+ exportLabel: "VOL",
+ experimental: true,
+ },
+ newSession: {
+ id: "newSession",
+ shortcut: "⌘ N",
+ icon: Command,
+ controlTypes: ["key"],
+ exportLabel: "NEW",
+ },
+ send: {
+ id: "send",
+ shortcut: "↩",
+ icon: ClaudeMarkIcon,
+ controlTypes: ["key"],
+ exportLabel: "SEND",
+ },
+ sendInDuplicateSession: {
+ id: "sendInDuplicateSession",
+ shortcut: "⌥ ⌘ ↩",
+ icon: CopyPlus,
+ controlTypes: ["key"],
+ exportLabel: "DUP",
+ },
+ voice: {
+ id: "voice",
+ shortcut: "⌘ D",
+ icon: Mic,
+ controlTypes: ["key"],
+ exportLabel: "VOICE",
+ },
+ diff: {
+ id: "diff",
+ shortcut: "⌘ ⇧ D",
+ icon: Diff,
+ controlTypes: ["key"],
+ exportLabel: "DIFF",
+ },
+ // Claude Desktop exposes cycling, not rank-based session selection.
+ nextSession: {
+ id: "nextSession",
+ shortcut: "⌃ ⇥",
+ icon: ChevronsRight,
+ controlTypes: ["key"],
+ exportLabel: "NEXT",
+ },
+ previousSession: {
+ id: "previousSession",
+ shortcut: "⌃ ⇧ ⇥",
+ icon: ChevronsLeft,
+ controlTypes: ["key"],
+ exportLabel: "PREV",
+ },
+ effortMenu: {
+ id: "effortMenu",
+ shortcut: "⌘ ⇧ E",
+ icon: Gauge,
+ controlTypes: ["key"],
+ exportLabel: "EFF",
+ },
+ stop: {
+ id: "stop",
+ shortcut: "Esc",
+ icon: Square,
+ controlTypes: ["key"],
+ exportLabel: "ESC",
+ },
+ settings: {
+ id: "settings",
+ shortcut: "⌘ ,",
+ icon: Settings,
+ controlTypes: ["key"],
+ exportLabel: "SET",
+ },
+ find: {
+ id: "find",
+ shortcut: "⌘ F",
+ icon: Search,
+ controlTypes: ["key"],
+ exportLabel: "FIND",
+ },
+ findNext: {
+ id: "findNext",
+ shortcut: "⌘ G",
+ icon: Search,
+ controlTypes: ["key"],
+ exportLabel: "NEXT",
+ },
+ findPrevious: {
+ id: "findPrevious",
+ shortcut: "⌘ ⇧ G",
+ icon: Search,
+ controlTypes: ["key"],
+ exportLabel: "PREV",
+ },
+ back: {
+ id: "back",
+ shortcut: "⌘ [",
+ icon: ArrowLeft,
+ controlTypes: ["key"],
+ exportLabel: "BACK",
+ },
+ forward: {
+ id: "forward",
+ shortcut: "⌘ ]",
+ icon: ArrowRight,
+ controlTypes: ["key"],
+ exportLabel: "FWD",
+ },
+ reload: {
+ id: "reload",
+ shortcut: "⌘ R",
+ icon: RotateCw,
+ controlTypes: ["key"],
+ exportLabel: "LOAD",
+ },
+ closeWindow: {
+ id: "closeWindow",
+ shortcut: "⌘ W",
+ icon: X,
+ controlTypes: ["key"],
+ exportLabel: "CLOSE",
+ },
+ zoomIn: {
+ id: "zoomIn",
+ shortcut: "⌘ +",
+ icon: ZoomIn,
+ controlTypes: ["key"],
+ exportLabel: "ZOOM+",
+ },
+ zoomOut: {
+ id: "zoomOut",
+ shortcut: "⌘ −",
+ icon: ZoomOut,
+ controlTypes: ["key"],
+ exportLabel: "ZOOM−",
+ },
+ resetZoom: {
+ id: "resetZoom",
+ shortcut: "⌘ 0",
+ icon: Monitor,
+ controlTypes: ["key"],
+ exportLabel: "100%",
+ },
+ none: {
+ id: "none",
+ shortcut: null,
+ icon: Minus,
+ controlTypes: ["key", "dial", "joystick"],
+ exportLabel: "NONE",
+ },
+};
+
+for (const action of Object.values(actions)) {
+ Object.freeze(action.controlTypes);
+ Object.freeze(action);
+}
+
+export const ACTIONS = Object.freeze(actions);
+assertKeyActionCatalog(ACTIONS);
+
+const actionsByControlType = { key: [], dial: [], joystick: [] };
+for (const action of Object.values(ACTIONS)) {
+ for (const controlType of action.controlTypes) {
+ actionsByControlType[controlType].push(action);
+ }
+}
+for (const groupedActions of Object.values(actionsByControlType)) {
+ Object.freeze(groupedActions);
+}
+
+export const ACTIONS_BY_CONTROL_TYPE = Object.freeze(actionsByControlType);
+export const FINAL_KEY_OPTIONS = Object.freeze(Object.keys(FINAL_KEYCODES));
diff --git a/prototype/src/configurator-presenter.js b/prototype/src/configurator-presenter.js
new file mode 100644
index 0000000..b8724b4
--- /dev/null
+++ b/prototype/src/configurator-presenter.js
@@ -0,0 +1,45 @@
+import { Keyboard, Move } from "lucide-react";
+import { ACTIONS } from "./configurator-catalog.jsx";
+import {
+ formatCustomKeys,
+ isCustom,
+ isCustomJoystick,
+} from "./configurator-state.js";
+
+export function entryFor(mapping, controlId) {
+ return mapping[controlId] ?? "none";
+}
+
+export function entryExportLabel(entry) {
+ if (isCustomJoystick(entry)) return `${entry.directions} DIR`;
+ if (isCustom(entry)) return formatCustomKeys(entry.keys);
+ return ACTIONS[entry]?.exportLabel ?? ACTIONS.none.exportLabel;
+}
+
+export function entryLabel(entry, t) {
+ if (isCustomJoystick(entry)) return t("actions.joystickCustom.label");
+ if (isCustom(entry)) return t("actions.custom.label");
+ return t(`actions.${entry}.label`);
+}
+
+export function entryIcon(entry) {
+ if (isCustomJoystick(entry)) return Move;
+ if (isCustom(entry)) return Keyboard;
+ return ACTIONS[entry]?.icon ?? ACTIONS.none.icon;
+}
+
+export function entryShortcut(entry, t) {
+ if (isCustomJoystick(entry)) {
+ return t("actions.joystickCustom.shortcut", { count: entry.directions });
+ }
+ if (isCustom(entry)) return formatCustomKeys(entry.keys);
+ return ACTIONS[entry]?.shortcut ?? t(`actions.${entry}.shortcut`);
+}
+
+export function controlLabel(control, t) {
+ return t(`controls.${control.id}`);
+}
+
+export function controlBadgeLabel(control, entry) {
+ return entry === "none" ? control.shortLabel : entryExportLabel(entry);
+}
diff --git a/prototype/src/configurator-state.js b/prototype/src/configurator-state.js
new file mode 100644
index 0000000..e3fd76d
--- /dev/null
+++ b/prototype/src/configurator-state.js
@@ -0,0 +1,223 @@
+import {
+ ACTION_DEFINITIONS,
+ DEFAULT_MAPPING,
+ FINAL_KEYCODES,
+ JOYSTICK_DIRECTION_COUNTS,
+ WHEEL_MODES,
+} from "../../shared/input-profile.mjs";
+
+export const MAPPING_STORAGE_KEY = "codex-micro-mapping";
+export const MODIFIERS = Object.freeze(["Command", "Shift", "Option", "Control"]);
+export const MODIFIER_SYMBOLS = Object.freeze({
+ Command: "⌘",
+ Shift: "⇧",
+ Option: "⌥",
+ Control: "⌃",
+});
+export const KEY_SYMBOLS = Object.freeze({
+ ArrowUp: "↑",
+ ArrowDown: "↓",
+ ArrowLeft: "←",
+ ArrowRight: "→",
+ PageUp: "Pg↑",
+ PageDown: "Pg↓",
+ Escape: "Esc",
+ Space: "␣",
+ Comma: ",",
+ BracketLeft: "[",
+ BracketRight: "]",
+ Equal: "=",
+ Minus: "−",
+});
+export const DEFAULT_CUSTOM = Object.freeze({
+ type: "custom",
+ keys: Object.freeze(["Command", "K"]),
+});
+
+const KEY_CONTROL_IDS = new Set(
+ Object.keys(DEFAULT_MAPPING).filter((controlId) => controlId.startsWith("key-")),
+);
+const KEY_ACTION_IDS = new Set(Object.keys(ACTION_DEFINITIONS));
+const JOYSTICK_ACTION_IDS = new Set(["navigation", "none"]);
+
+export function assertKeyActionCatalog(actions) {
+ const missing = [...KEY_ACTION_IDS].filter(
+ (actionId) => !actions[actionId]?.controlTypes?.includes("key"),
+ );
+ if (missing.length > 0) {
+ throw new Error(`The configurator is missing key actions: ${missing.join(", ")}`);
+ }
+}
+
+export function isCustom(entry) {
+ return typeof entry === "object" && entry !== null && entry.type === "custom";
+}
+
+export function isCustomJoystick(entry) {
+ return typeof entry === "object" && entry !== null && Array.isArray(entry.sectors);
+}
+
+export function makeCustomJoystick(directions, previous = []) {
+ return {
+ directions,
+ sectors: Array.from({ length: directions }, (_, index) => previous[index] ?? "none"),
+ };
+}
+
+export function formatCustomKeys(keys) {
+ const modifiers = keys.slice(0, -1);
+ const finalKey = keys.at(-1);
+ const modifierText = modifiers.map((key) => MODIFIER_SYMBOLS[key] ?? key).join("");
+ return `${modifierText}${KEY_SYMBOLS[finalKey] ?? finalKey}`;
+}
+
+export function isValidEntry(controlId, entry) {
+ if (controlId === "joystick") {
+ if (isCustomJoystick(entry)) {
+ return (
+ JOYSTICK_DIRECTION_COUNTS.includes(entry.directions) &&
+ entry.sectors.length === entry.directions &&
+ entry.sectors.every((sector) => isValidEntry("key-1", sector))
+ );
+ }
+ return JOYSTICK_ACTION_IDS.has(entry);
+ }
+ if (controlId === "wheel") {
+ return typeof entry === "string" && Object.hasOwn(WHEEL_MODES, entry);
+ }
+ if (!KEY_CONTROL_IDS.has(controlId)) return false;
+ if (isCustom(entry)) {
+ if (!Array.isArray(entry.keys) || entry.keys.length === 0) return false;
+ const modifiers = entry.keys.slice(0, -1);
+ const finalKey = entry.keys.at(-1);
+ return (
+ typeof finalKey === "string" &&
+ Object.hasOwn(FINAL_KEYCODES, finalKey) &&
+ modifiers.every((modifier, index) =>
+ typeof modifier === "string" &&
+ MODIFIERS.includes(modifier) &&
+ modifiers.indexOf(modifier) === index,
+ )
+ );
+ }
+ return KEY_ACTION_IDS.has(entry);
+}
+
+export function entryFingerprint(entry) {
+ if (isCustom(entry)) return JSON.stringify(["custom", entry.keys]);
+ if (isCustomJoystick(entry)) {
+ return JSON.stringify([
+ "joystick",
+ entry.directions,
+ entry.sectors.map((sector) => entryFingerprint(sector)),
+ ]);
+ }
+ return JSON.stringify(entry ?? "none");
+}
+
+export function mappingsEqual(left, right) {
+ const controlIds = new Set([...Object.keys(left), ...Object.keys(right)]);
+ for (const controlId of controlIds) {
+ if (entryFingerprint(left[controlId]) !== entryFingerprint(right[controlId])) return false;
+ }
+ return true;
+}
+
+export function loadStoredState(storage) {
+ const fallback = { mapping: DEFAULT_MAPPING };
+ try {
+ const target = storage ?? globalThis.window?.localStorage;
+ const raw = target?.getItem(MAPPING_STORAGE_KEY);
+ if (!raw) return fallback;
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed.mapping !== "object" || Array.isArray(parsed.mapping)) {
+ return fallback;
+ }
+ const mapping = {};
+ for (const [controlId, entry] of Object.entries(parsed.mapping)) {
+ if (isValidEntry(controlId, entry)) mapping[controlId] = entry;
+ }
+ for (const controlId of Object.keys(DEFAULT_MAPPING)) {
+ if (!(controlId in mapping)) mapping[controlId] = DEFAULT_MAPPING[controlId];
+ }
+ return { mapping };
+ } catch {
+ return fallback;
+ }
+}
+
+export function saveStoredState(mapping, storage) {
+ try {
+ const target = storage ?? globalThis.window?.localStorage;
+ target?.setItem(MAPPING_STORAGE_KEY, JSON.stringify({ mapping }));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function assignMappingEntry(mapping, controlId, joystickSlot, entry) {
+ if (controlId !== "joystick" || joystickSlot === null) {
+ return { ...mapping, [controlId]: entry };
+ }
+
+ const joystick = mapping.joystick;
+ if (!isCustomJoystick(joystick) || !Number.isInteger(joystickSlot)) return mapping;
+ if (joystickSlot < 0 || joystickSlot >= joystick.sectors.length) return mapping;
+
+ const sectors = joystick.sectors.map((sector, index) =>
+ index === joystickSlot ? entry : sector,
+ );
+ return { ...mapping, joystick: { ...joystick, sectors } };
+}
+
+export function setJoystickMappingMode(mapping, mode) {
+ if (mode === "navigation" || mode === "none") {
+ return { ...mapping, joystick: mode };
+ }
+ if (!JOYSTICK_DIRECTION_COUNTS.includes(mode)) return mapping;
+ const previous = isCustomJoystick(mapping.joystick) ? mapping.joystick.sectors : [];
+ return { ...mapping, joystick: makeCustomJoystick(mode, previous) };
+}
+
+export function toggleShortcutModifier(keys, modifier) {
+ if (!MODIFIERS.includes(modifier) || keys.length === 0) return keys;
+ const finalKey = keys.at(-1);
+ const active = new Set(keys.slice(0, -1));
+ if (active.has(modifier)) active.delete(modifier);
+ else active.add(modifier);
+ return [...MODIFIERS.filter((candidate) => active.has(candidate)), finalKey];
+}
+
+export function replaceShortcutFinalKey(keys, finalKey) {
+ if (
+ keys.length === 0 ||
+ typeof finalKey !== "string" ||
+ !Object.hasOwn(FINAL_KEYCODES, finalKey)
+ ) {
+ return keys;
+ }
+ return [...keys.slice(0, -1), finalKey];
+}
+
+export function indexDuplicateKeyControls(controls, mapping, selectedControlId) {
+ const byEntry = new Map();
+ for (const control of controls) {
+ if (control.type !== "key" || control.id === selectedControlId) continue;
+ const fingerprint = entryFingerprint(mapping[control.id]);
+ if (!byEntry.has(fingerprint)) byEntry.set(fingerprint, control);
+ }
+ return byEntry;
+}
+
+export function parseOptionalNonNegativeInteger(value) {
+ const trimmed = String(value).trim();
+ if (trimmed === "") return undefined;
+ const parsed = Number(trimmed);
+ if (!Number.isInteger(parsed) || parsed < 0) {
+ const error = new Error("Expected a non-negative integer.");
+ error.code = "INVALID_APPSENSE_ID";
+ throw error;
+ }
+ return parsed;
+}
diff --git a/prototype/src/hooks/use-theme.js b/prototype/src/hooks/use-theme.js
new file mode 100644
index 0000000..c096cd8
--- /dev/null
+++ b/prototype/src/hooks/use-theme.js
@@ -0,0 +1,49 @@
+import { useCallback, useEffect, useState } from "react";
+
+export const THEME_STORAGE_KEY = "codex-micro-theme";
+export const THEME_ORDER = Object.freeze(["auto", "light", "dark"]);
+
+export function detectTheme(storage) {
+ try {
+ const target = storage ?? globalThis.window?.localStorage;
+ const saved = target?.getItem(THEME_STORAGE_KEY);
+ return THEME_ORDER.includes(saved) ? saved : "auto";
+ } catch {
+ return "auto";
+ }
+}
+
+export function nextTheme(theme) {
+ const index = THEME_ORDER.indexOf(theme);
+ return THEME_ORDER[(index + 1) % THEME_ORDER.length] ?? THEME_ORDER[0];
+}
+
+export function useTheme() {
+ const [theme, setTheme] = useState(detectTheme);
+
+ useEffect(() => {
+ const media = window.matchMedia?.("(prefers-color-scheme: dark)");
+ const apply = () => {
+ const prefersDark = media?.matches ?? false;
+ document.documentElement.dataset.theme =
+ theme === "auto" ? (prefersDark ? "dark" : "light") : theme;
+ };
+
+ apply();
+ try {
+ window.localStorage.setItem(THEME_STORAGE_KEY, theme);
+ } catch {
+ // Private browsing: the preference just will not persist.
+ }
+
+ if (theme !== "auto" || !media?.addEventListener) return undefined;
+ media.addEventListener("change", apply);
+ return () => media.removeEventListener("change", apply);
+ }, [theme]);
+
+ const cycleTheme = useCallback(() => {
+ setTheme((current) => nextTheme(current));
+ }, []);
+
+ return { theme, cycleTheme };
+}
diff --git a/prototype/src/i18n/de.js b/prototype/src/i18n/de.js
index 775b1d6..d0ebbbd 100644
--- a/prototype/src/i18n/de.js
+++ b/prototype/src/i18n/de.js
@@ -28,6 +28,7 @@ export default {
},
stateLegend: {
title: "Farben der Agent-Tasten",
+ experimental: "experimentell",
off: "aus",
states: {
blocked: "Eine Entscheidung wird erwartet",
@@ -38,7 +39,7 @@ export default {
free: "Keine Sitzung auf dieser Taste",
},
note:
- "Von „npm run lighting -- watch“ aus dem echten Zustand der Claude-Code-Sitzungen gesendet. Die ChatGPT-App muss beendet sein: sie überschreibt diese LEDs alle 35 bis 40 Sekunden und fängt die Tastendrücke ab.",
+ "Von „npm run lighting -- watch“ aus dem echten Zustand der Claude-Code-Sitzungen gesendet. Die ChatGPT-App muss beendet sein: sie überschreibt diese LEDs alle 35 bis 40 Sekunden und fängt die Tastendrücke ab. Nichts anderes hängt davon ab: Schlägt die Beleuchtung fehl, senden die Tasten weiterhin ihre Kürzel.",
},
controls: {
joystick: "Richtungs-Joystick – ohne Druckfunktion",
diff --git a/prototype/src/i18n/en.js b/prototype/src/i18n/en.js
index 719a48c..356994a 100644
--- a/prototype/src/i18n/en.js
+++ b/prototype/src/i18n/en.js
@@ -28,6 +28,7 @@ export default {
},
stateLegend: {
title: "Agent key colours",
+ experimental: "experimental",
off: "unlit",
states: {
blocked: "A decision is waiting for you",
@@ -38,7 +39,7 @@ export default {
free: "No session on this key",
},
note:
- "Pushed by \"npm run lighting -- watch\" from live Claude Code session state. Quit the ChatGPT app first: it rewrites these LEDs every 35 to 40 seconds and intercepts the key presses.",
+ "Pushed by \"npm run lighting -- watch\" from live Claude Code session state. Quit the ChatGPT app first: it rewrites these LEDs every 35 to 40 seconds and intercepts the key presses. Nothing else depends on this: if the lighting fails, your keys keep sending their shortcuts.",
},
controls: {
joystick: "Directional joystick — no press",
diff --git a/prototype/src/i18n/es.js b/prototype/src/i18n/es.js
index 7d7f197..f0e5c1f 100644
--- a/prototype/src/i18n/es.js
+++ b/prototype/src/i18n/es.js
@@ -28,6 +28,7 @@ export default {
},
stateLegend: {
title: "Colores de las teclas Agent",
+ experimental: "experimental",
off: "apagada",
states: {
blocked: "Se espera una decisión",
@@ -38,7 +39,7 @@ export default {
free: "Ninguna sesión en esta tecla",
},
note:
- "Enviados por «npm run lighting -- watch» según el estado real de las sesiones de Claude Code. Cierra la app ChatGPT: reescribe estos LED cada 35 a 40 segundos e intercepta las pulsaciones.",
+ "Enviados por «npm run lighting -- watch» según el estado real de las sesiones de Claude Code. Cierra la app ChatGPT: reescribe estos LED cada 35 a 40 segundos e intercepta las pulsaciones. Nada más depende de esto: si la iluminación falla, tus teclas siguen enviando sus atajos.",
},
controls: {
joystick: "Joystick direccional — sin clic",
diff --git a/prototype/src/i18n/fr.js b/prototype/src/i18n/fr.js
index b9b9773..0979f9a 100644
--- a/prototype/src/i18n/fr.js
+++ b/prototype/src/i18n/fr.js
@@ -28,6 +28,7 @@ export default {
},
stateLegend: {
title: "Couleurs des touches Agent",
+ experimental: "expérimental",
off: "éteinte",
states: {
blocked: "Une décision est attendue",
@@ -38,7 +39,7 @@ export default {
free: "Aucune session sur cette touche",
},
note:
- "Poussées par « npm run lighting -- watch » d'après l'état réel des sessions Claude Code. L'app ChatGPT doit être quittée : elle réécrit ces LED toutes les 35 à 40 secondes et intercepte les appuis.",
+ "Poussées par « npm run lighting -- watch » d'après l'état réel des sessions Claude Code. L'app ChatGPT doit être quittée : elle réécrit ces LED toutes les 35 à 40 secondes et intercepte les appuis. Rien d'autre n'en dépend : si l'éclairage échoue, vos touches continuent d'envoyer leurs raccourcis.",
},
controls: {
joystick: "Joystick directionnel — sans clic",
diff --git a/prototype/src/i18n/index.js b/prototype/src/i18n/index.js
index 9a5019b..c158a0e 100644
--- a/prototype/src/i18n/index.js
+++ b/prototype/src/i18n/index.js
@@ -15,21 +15,23 @@ export const LOCALE_LABELS = {
const STORAGE_KEY = "codex-micro-locale";
const DEFAULT_LOCALE = "en";
-export function detectLocale() {
+export function detectLocale(storage) {
try {
- const saved = window.localStorage.getItem(STORAGE_KEY);
+ const target = storage ?? globalThis.window?.localStorage;
+ const saved = target?.getItem(STORAGE_KEY);
if (saved && LOCALES[saved]) return saved;
} catch {
- // Stockage indisponible : on retombe sur la langue par défaut.
+ // Storage unavailable: fall back to the default language.
}
return DEFAULT_LOCALE;
}
-export function saveLocale(locale) {
+export function saveLocale(locale, storage) {
try {
- window.localStorage.setItem(STORAGE_KEY, locale);
+ const target = storage ?? globalThis.window?.localStorage;
+ target?.setItem(STORAGE_KEY, locale);
} catch {
- // Ignoré : la préférence ne sera simplement pas mémorisée.
+ // Ignored: the preference simply will not be remembered.
}
}
diff --git a/prototype/src/profile-panel-loader.js b/prototype/src/profile-panel-loader.js
new file mode 100644
index 0000000..5379c02
--- /dev/null
+++ b/prototype/src/profile-panel-loader.js
@@ -0,0 +1,6 @@
+let profilePanelPromise;
+
+export function loadProfileExportPanel() {
+ profilePanelPromise ??= import("./components/ProfileExportPanel.jsx");
+ return profilePanelPromise;
+}
diff --git a/prototype/src/profile-session.js b/prototype/src/profile-session.js
new file mode 100644
index 0000000..acd6065
--- /dev/null
+++ b/prototype/src/profile-session.js
@@ -0,0 +1,49 @@
+export function createProfileSession() {
+ return {
+ source: null,
+ fileName: "",
+ info: null,
+ error: null,
+ mappingConflict: null,
+ layerCreated: null,
+ review: null,
+ appSenseIds: { claude: "", base: "" },
+ };
+}
+
+export function profileSessionReducer(state, action) {
+ switch (action.type) {
+ case "profile-loaded":
+ return {
+ ...state,
+ source: action.payload.source,
+ fileName: action.payload.fileName,
+ info: action.payload.info,
+ layerCreated: action.payload.layerCreated,
+ mappingConflict: action.payload.mappingConflict,
+ error: null,
+ review: null,
+ };
+ case "profile-failed":
+ return { ...state, error: action.error };
+ case "conflict-resolved":
+ return state.mappingConflict ? { ...state, mappingConflict: null } : state;
+ case "appsense-changed":
+ if (action.field !== "claude" && action.field !== "base") {
+ throw new Error(`Unknown AppSense field: ${action.field}`);
+ }
+ return {
+ ...state,
+ appSenseIds: { ...state.appSenseIds, [action.field]: action.value },
+ review: null,
+ };
+ case "review-ready":
+ return { ...state, review: action.review, error: null };
+ case "review-failed":
+ return { ...state, review: null, error: action.error };
+ case "review-invalidated":
+ return state.review ? { ...state, review: null } : state;
+ default:
+ throw new Error(`Unknown profile session action: ${action.type}`);
+ }
+}
diff --git a/prototype/src/profile-workflow.js b/prototype/src/profile-workflow.js
new file mode 100644
index 0000000..307265c
--- /dev/null
+++ b/prototype/src/profile-workflow.js
@@ -0,0 +1,69 @@
+import {
+ addClaudeLayer,
+ buildInputProfile,
+ deriveMappingFromProfile,
+ inspectInputProfile,
+} from "../../shared/input-profile.mjs";
+import { parseOptionalNonNegativeInteger } from "./configurator-state.js";
+import { LOCALES } from "./i18n/index.js";
+
+export function prepareImportedProfile(parsed) {
+ let source = parsed;
+ let layerCreated = null;
+ let info;
+
+ try {
+ info = inspectInputProfile(source, { requireAppSense: false });
+ } catch (error) {
+ if (error?.code !== "NO_CLAUDE_LAYER") throw error;
+ const synthesized = addClaudeLayer(parsed);
+ source = synthesized.source;
+ layerCreated = { templateName: synthesized.templateName };
+ info = inspectInputProfile(source, { requireAppSense: false });
+ }
+
+ return {
+ source,
+ info,
+ layerCreated,
+ derived: deriveMappingFromProfile(source),
+ };
+}
+
+export async function sha256Hex(text, crypto = globalThis.window?.crypto) {
+ try {
+ const digest = await crypto?.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(text),
+ );
+ if (!digest) return "";
+ return Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+ } catch {
+ return "";
+ }
+}
+
+export async function createProfileReview(source, mapping, appSenseIds, crypto) {
+ const { profile, report } = buildInputProfile(source, mapping, {
+ requireAppSense: false,
+ appSenseId: parseOptionalNonNegativeInteger(appSenseIds.claude),
+ baseLayerAppSenseId: parseOptionalNonNegativeInteger(appSenseIds.base),
+ });
+ const json = `${JSON.stringify(profile, null, 2)}\n`;
+ return { json, sha: await sha256Hex(json, crypto), report };
+}
+
+export function describeProfileError(error, t) {
+ if (error instanceof SyntaxError) {
+ return { message: t("errors.invalidJson"), code: null };
+ }
+ if (error?.code && LOCALES.fr.errors[error.code]) {
+ return { message: t(`errors.${error.code}`), code: error.code };
+ }
+ return {
+ message: error instanceof Error ? error.message : t("errors.invalidFile"),
+ code: null,
+ };
+}
diff --git a/prototype/src/styles.css b/prototype/src/styles.css
index 9af86e5..b0fc399 100644
--- a/prototype/src/styles.css
+++ b/prototype/src/styles.css
@@ -15,6 +15,8 @@
--shadow: rgba(126, 73, 52, 0.2);
--glass-border: rgba(255, 255, 255, 0.55);
--glass-blur: blur(18px) saturate(1.6);
+ --solid-glass-background: #fffaf6;
+ --solid-toast-background: #453a36;
}
* {
@@ -58,9 +60,9 @@ a:focus-visible {
--panel-reserve: 0px;
}
-/* Panneau non modal sur desktop : la page se décale vers la gauche pour que
- le clavier reste entièrement visible à côté du configurateur. Sur mobile la
- sheet recouvre la page, aucune réservation (voir la media query dédiée). */
+/* Non-modal panel on desktop: the page shifts left so the keyboard stays fully
+ visible next to the configurator. On mobile the sheet covers the page, with no
+ reservation (see the dedicated media query). */
@media (min-width: 761px) {
.app-shell.is-panel-open {
--panel-reserve: calc(min(470px, 46vw) + 30px);
@@ -91,8 +93,8 @@ a:focus-visible {
position: absolute;
z-index: 3;
top: 0;
- /* Position absolue : le padding de .page-content ne s'applique pas,
- le décalage du panneau passe donc par `right`. */
+ /* Absolutely positioned: .page-content's padding does not apply, so the
+ panel offset goes through `right` instead. */
right: var(--panel-reserve);
left: 0;
transition: right 280ms cubic-bezier(0.2, 0.9, 0.25, 1);
@@ -236,22 +238,21 @@ a:focus-visible {
.device-stage {
position: relative;
width: min(510px, 72vh, 88vw);
- /* Quand le panneau réserve la droite de l'écran, le device se réduit pour
- rester entièrement visible dans la colonne restante. */
+ /* When the panel reserves the right of the screen, the device shrinks to stay
+ fully visible in the remaining column. */
max-width: 100%;
margin-top: 20px;
}
-/* La boîte est recadrée sur le device lui-même, pas sur le cadre du PNG.
- Le fichier est un détourage de 1254x1254 dont la partie opaque n'occupe que
- 851x855, soit 68% : cadrer sur le fichier gaspillait 32% de la largeur en
- transparent et rendait le clavier d'autant plus petit. Le recadrage se fait
- ici, sans toucher à l'asset — l'image est agrandie et décalée pour que sa
- zone opaque remplisse exactement la boîte.
+/* The box is cropped to the device itself, not to the PNG's frame. The file is a
+ 1254x1254 cut-out whose opaque part only occupies 851x855, so 68%: framing on
+ the file wasted 32% of the width on transparency and made the keyboard that
+ much smaller. The crop happens here, without touching the asset — the image is
+ scaled up and offset so its opaque area fills the box exactly.
- Conséquence : les coordonnées des hotspots dans App.jsx sont des pourcentages
- de CE cadre, pas du fichier. Remplacer l'image impose de les recalculer, et
- les valeurs en `cqw` ci-dessous suivent la même référence. */
+ Consequence: the hotspot coordinates in App.jsx are percentages of THIS frame,
+ not of the file. Replacing the image forces them to be recomputed, and the
+ `cqw` values below follow the same reference. */
.device-wrap {
position: relative;
width: 100%;
@@ -313,8 +314,8 @@ a:focus-visible {
border-color: rgb(var(--selected-glow) / 1);
background: rgba(255, 255, 255, 0.36);
transform: translateY(-1px) scale(1.03);
- /* Liseré blanc entre la touche et l'anneau accent : la sélection reste
- lisible même sur les zones claires ou chargées de la photo. */
+ /* White hairline between the key and the accent ring: the selection stays
+ readable even over light or busy areas of the photo. */
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.95),
0 0 0 7px rgb(var(--selected-glow) / 0.95),
@@ -352,11 +353,10 @@ a:focus-visible {
box-shadow:
0 3px 10px rgba(45, 28, 20, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
- /* Taille indexée sur la photo, pas sur le viewport : le plafond `max-width`
- de la pastille se réduit avec la touche, donc un libellé en pixels fixes
- finit tronqué aux petites tailles — « VOICE » se coupait en « VO… » dès que
- le device passait sous ~330px. Les bornes du clamp reproduisent exactement
- l'aspect actuel à la grande taille. */
+ /* Sized against the photo, not the viewport: the pill's `max-width` ceiling
+ shrinks with the key, so a label in fixed pixels ends up truncated at small
+ sizes — "VOICE" was cut to "VO…" as soon as the device went below ~330px.
+ The clamp bounds reproduce exactly the current look at the large size. */
padding: clamp(2px, 1.18cqw, 4px) clamp(3px, 1.77cqw, 6px);
font: 800 clamp(7px, 2.73cqw, 9px) / 1 "SFMono-Regular", Consolas, monospace;
letter-spacing: 0.025em;
@@ -365,12 +365,11 @@ a:focus-visible {
transition: background 140ms ease, box-shadow 140ms ease;
}
-/* Empilement de la molette, en trois étages qui ne peuvent pas se croiser :
- le libellé du clic au-dessus du cercle, l'icône du clic centrée dedans, le
- libellé de rotation sous le cercle. Tout est exprimé en `cqw` pour que les
- trois gardent leurs écarts quelle que soit la taille de la photo — c'est le
- mélange de pixels fixes et de pourcentages qui faisait chevaucher l'icône du
- clic et le libellé de rotation. */
+/* The wheel stack, in three tiers that cannot cross: the press label above the
+ circle, the press icon centred inside it, the rotation label below the circle.
+ Everything is expressed in `cqw` so the three keep their spacing whatever the
+ size of the photo — it was the mix of fixed pixels and percentages that made
+ the press icon and the rotation label overlap. */
.key-hotspot-label--dial {
display: inline-flex;
bottom: -2.06cqw;
@@ -385,14 +384,14 @@ a:focus-visible {
flex: none;
}
-/* Les deux libellés de la molette débordent volontairement de la boîte de leur
- bouton, et celui du clic recouvre le cercle de rotation. Avec
- `pointer-events: none` le pointeur les traversait et atteignait le contrôle
- du dessous : survoler « PRESS » mettait la molette en avant. Les rendre
- sensibles au pointeur suffit — ce sont des enfants de leur bouton, le survol
- et le clic remontent donc au bon contrôle, et le contrôle voisin n'est plus
- atteint. Les autres libellés gardent `none` : ils ne dépassent que de
- quelques pixels sous leur touche, où il n'y a rien à protéger. */
+/* Both wheel labels deliberately overflow their button's box, and the press one
+ covers the rotation circle. With `pointer-events: none` the pointer went
+ straight through them and reached the control underneath: hovering "PRESS"
+ highlighted the wheel. Making them pointer-sensitive is enough — they are
+ children of their own button, so hover and click bubble to the right control
+ and the neighbouring one is no longer reached. The other labels keep `none`:
+ they only overhang their key by a few pixels, where there is nothing to
+ protect. */
.key-hotspot--dial > .key-hotspot-label,
.key-hotspot--encoder-button > .key-hotspot-label {
pointer-events: auto;
@@ -412,20 +411,25 @@ a:focus-visible {
.keycap-action-asset {
position: absolute;
- top: 43%;
+ /* Centré sur le hotspot, qui recouvre la face de la keycap. */
+ top: 50%;
left: 50%;
display: grid;
- width: 10.98cqw;
- height: 10.98cqw;
+ /* Le disque doit couvrir la sérigraphie de la keycap sur la photo, sinon le
+ picto d'origine reste visible derrière l'icône de l'action. Il est donc
+ nettement plus large que l'icône, et sa zone pleine est poussée vers
+ l'extérieur — le dégradé ne s'éteint qu'au bord. */
+ width: 13.2cqw;
+ height: 13.2cqw;
place-items: center;
transform: translate(-50%, -50%);
border: 0;
border-radius: 50%;
background: radial-gradient(
circle,
- rgb(var(--keycap-tone) / 0.98) 0 48%,
- rgb(var(--keycap-tone) / 0.9) 62%,
- rgb(var(--keycap-tone) / 0) 82%
+ rgb(var(--keycap-tone) / 0.99) 0 62%,
+ rgb(var(--keycap-tone) / 0.94) 76%,
+ rgb(var(--keycap-tone) / 0) 94%
);
color: rgba(62, 46, 39, 0.96);
box-shadow: none;
@@ -438,8 +442,8 @@ a:focus-visible {
transform: translate(-50%, -50%) scale(1.06);
}
-/* Étage du milieu : nettement plus petit que le cercle du clic, pour laisser
- respirer les deux libellés au-dessus et en dessous. */
+/* Middle tier: clearly smaller than the press circle, to let both labels above
+ and below breathe. */
.key-hotspot--encoder-button > .keycap-action-asset {
top: 50%;
width: 6.04cqw;
@@ -447,13 +451,18 @@ a:focus-visible {
color: #fffaf6;
}
-/* Les icônes sont rendues avec une taille en attribut par React : les
- contraindre en CSS pour qu'elles suivent leur disque, lui-même indexé sur la
- photo. Les proportions reprennent celles d'origine, 19px dans 38px pour une
- keycap et 13px dans 24px pour le clic de molette. */
-.keycap-action-asset > svg {
- width: 50%;
- height: 50%;
+/* React renders the icons with a size attribute: constrain them in CSS so they
+ follow their disc, which is itself sized against the photo. The proportions
+ keep the original ones, 19px within 38px for a keycap and 13px within 24px for
+ the wheel press. */
+/* La marque Claude est un `img` porteur d'attributs de taille, pas un `svg` :
+ sans cette règle la touche Envoyer gardait une icône figée pendant que les
+ autres suivaient la photo. Le pourcentage est calculé pour que l'icône garde
+ sa taille absolue après l'élargissement du disque. */
+.keycap-action-asset > svg,
+.keycap-action-asset > .claude-mark-icon {
+ width: 35%;
+ height: 35%;
}
.key-hotspot--encoder-button > .keycap-action-asset > svg {
@@ -461,7 +470,7 @@ a:focus-visible {
height: 54%;
}
-/* Étage du haut : posé sur le bandeau vide au-dessus de la molette. */
+/* Top tier: sits on the empty band above the wheel. */
.key-hotspot--encoder-button > .key-hotspot-label {
top: -5.01cqw;
bottom: auto;
@@ -514,18 +523,15 @@ a:focus-visible {
font-size: 12px;
}
-/* Légende des couleurs d'état des six touches Agent. Les teintes ne sont pas
- écrites ici : elles viennent en style inline de shared/thread-status-palette.mjs,
- source unique partagée avec l'outillage Node. */
-/* Légende des couleurs d'état des six touches Agent. Les teintes ne sont pas
- écrites ici : elles arrivent en style inline depuis
- shared/thread-status-palette.mjs, source unique partagée avec l'outillage Node.
+/* State colour legend for the six Agent keys. The hues are not written here:
+ they arrive as inline styles from shared/thread-status-palette.mjs, the single
+ source shared with the Node tooling.
- Hors du flux, dans la marge gauche du clavier, à mi-hauteur. La colonne centrale
- du héros n'a plus de budget vertical avant la ligne de flottaison et la coque ne
- défile pas : une légende dans le flux y serait inatteignable. Le bas de page
- n'est pas libre non plus — les deux notes centrées y courent sur presque toute
- la largeur. Reste la marge latérale, face au clavier. */
+ Out of the flow, in the left margin of the keyboard, at mid-height. The hero's
+ centre column has no vertical budget left above the fold and the shell does not
+ scroll: a legend in the flow would be unreachable there. The bottom of the page
+ is not free either — the two centred notes run across almost its whole width.
+ That leaves the side margin, facing the keyboard. */
.state-legend {
position: fixed;
z-index: 4;
@@ -553,8 +559,24 @@ a:focus-visible {
margin-bottom: 10px;
}
-/* Une seule colonne : la carte est étroite, et l'ordre de lecture va du plus
- urgent au plus inerte. */
+/* Lighting is the experimental half of the project: say so here, and not only in
+ the README — otherwise the interface is what users go by, and it promises a
+ stable feature. */
+.state-legend-badge {
+ margin-left: 8px;
+ padding: 1px 6px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ text-transform: lowercase;
+ vertical-align: 1px;
+}
+
+/* A single column: the card is narrow, and the reading order runs from the most
+ urgent to the most inert. */
.state-legend-list {
display: grid;
gap: 6px;
@@ -578,8 +600,7 @@ a:focus-visible {
border-radius: 4px;
}
-/* Un emplacement libre n'a pas de couleur : la pastille montre une touche
- éteinte, pas une teinte de plus. */
+/* A free slot has no colour: the swatch shows an unlit key, not one more hue. */
.state-legend-swatch.is-off {
background: transparent;
border-style: dashed;
@@ -604,10 +625,10 @@ a:focus-visible {
line-height: 1.45;
}
-/* La carte n'apparaît que là où la marge la contient réellement. Sous cette
- largeur elle recouvrirait le clavier : mieux vaut ne rien montrer qu'un panneau
- posé par-dessus le contenu. Même raison quand le panneau de configuration est
- ouvert : le héros se décale vers la gauche et la marge disparaît. */
+/* The card only appears where the margin actually holds it. Below this width it
+ would cover the keyboard: better to show nothing than a panel laid over the
+ content. Same reason when the configuration panel is open: the hero shifts left
+ and the margin disappears. */
@media (max-width: 1239px) {
.state-legend {
display: none;
@@ -619,11 +640,10 @@ a:focus-visible {
}
-/* Dans le flux, sous le clavier. Ce bouton était auparavant en position absolue
- par-dessus le bas du stage, qui était vide : le cadre de l'image laissait
- 18,6% de transparent sous le device. Depuis que la boîte est recadrée sur le
- device, ce vide n'existe plus et le bouton recouvrirait les touches. */
-.configure-button {
+/* Both primary actions share one stable footprint, so changing copy or language
+ does not move the device stage. */
+.configure-button,
+.generate-button {
position: relative;
z-index: 3;
display: inline-flex;
@@ -631,10 +651,17 @@ a:focus-visible {
min-height: 50px;
align-items: center;
justify-content: center;
+ border-radius: 999px;
+ font-size: 13px;
+ transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+}
+
+/* In the flow, below the keyboard. Its old absolute position covered keys once
+ the transparent image margin was removed. */
+.configure-button {
gap: 9px;
margin: 18px auto 0;
border: 1px solid rgba(255, 255, 255, 0.78);
- border-radius: 999px;
background: rgba(255, 251, 247, 0.6);
padding: 0 17px;
color: var(--ink);
@@ -642,10 +669,8 @@ a:focus-visible {
0 16px 42px rgba(112, 70, 51, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.92),
inset 0 -1px 1px rgba(255, 255, 255, 0.28);
- font-size: 13px;
font-weight: 720;
backdrop-filter: var(--glass-blur);
- transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.configure-button:hover {
@@ -661,19 +686,11 @@ a:focus-visible {
}
.generate-button {
- position: relative;
- z-index: 3;
- display: inline-flex;
- width: min(240px, calc(100vw - 32px));
- min-height: 50px;
- align-items: center;
- justify-content: center;
gap: 8px;
- /* Marge positive : ce bouton remontait de 44px pour combler le vide sous le
- device, vide supprimé par le recadrage de la boîte sur le clavier. */
+ /* Positive margin: this button used to pull up by 44px to fill the empty space
+ under the device, space removed by cropping the box to the keyboard. */
margin-top: 12px;
border: 1px solid rgba(255, 255, 255, 0.45);
- border-radius: 999px;
background: rgba(201, 95, 67, 0.82);
padding: 0 20px;
color: #fffaf6;
@@ -682,9 +699,7 @@ a:focus-visible {
inset 0 1px 0 rgba(255, 255, 255, 0.42),
inset 0 -1px 1px rgba(255, 255, 255, 0.14);
backdrop-filter: blur(14px) saturate(1.5);
- font-size: 13px;
font-weight: 750;
- transition: background 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.generate-button:hover {
@@ -754,9 +769,8 @@ a:focus-visible {
right: 12px;
bottom: 12px;
display: flex;
- /* 46vw : sur les desktops étroits, panneau et clavier se partagent
- l'écran au lieu de se recouvrir (la media query mobile prend le relais
- sous 760px). */
+ /* 46vw: on narrow desktops, panel and keyboard share the screen instead of
+ overlapping (the mobile media query takes over below 760px). */
width: min(470px, 46vw);
flex-direction: column;
overflow: hidden;
@@ -805,8 +819,8 @@ a:focus-visible {
letter-spacing: -0.045em;
}
-/* Mode « touche » : l'en-tête EST la touche sélectionnée — badge, nom et
- action actuelle — à la place d'un titre générique. */
+/* "Key" mode: the header IS the selected key — badge, name and current action —
+ instead of a generic title. */
.key-header {
display: flex;
min-width: 0;
@@ -851,8 +865,8 @@ a:focus-visible {
}
}
-/* Le contenu du panneau glisse en place à l'ouverture et au changement
- de mode (touche ↔ export). */
+/* The panel content slides into place on open and on mode change
+ (key ↔ export). */
.dialog-scroll > * {
animation: panel-swap-in 240ms cubic-bezier(0.2, 0.9, 0.25, 1);
}
@@ -946,10 +960,9 @@ kbd {
font-size: 13px;
}
-/* Deux lignes plutôt qu'une seule tronquée : la colonne du raccourci est en
- `auto`, donc les raccourcis larges comme celui du mode Effort écrasaient la
- description jusqu'à la rendre illisible. La hauteur de rangée de 67px les
- accueille sans décaler la liste. */
+/* Two lines rather than a single truncated one: the shortcut column is `auto`,
+ so wide shortcuts like the Effort one squeezed the description until it became
+ unreadable. The 67px row height fits them without shifting the list. */
.action-copy small {
display: -webkit-box;
overflow: hidden;
@@ -1379,8 +1392,8 @@ kbd {
line-height: 1.45;
}
-/* Roue de secteurs, dessinée depuis les mêmes angles que le profil : le
- secteur cliqué est celui qui sera écrit, à l'angle près. */
+/* Sector wheel, drawn from the same angles as the profile: the sector clicked is
+ the one that will be written, down to the angle. */
.joystick-dial-wrap {
position: relative;
width: min(228px, 78%);
@@ -1392,9 +1405,9 @@ kbd {
width: 100%;
}
-/* Même convention que les keycaps : le numéro du secteur tant qu'il est libre,
- le libellé de l'action une fois affectée. Purement décoratif — le secteur SVG
- dessous reste le seul contrôle. */
+/* Same convention as the keycaps: the sector number while it is free, the action
+ label once assigned. Purely decorative — the SVG sector underneath stays the
+ only control. */
.joystick-dial-badge {
position: absolute;
max-width: 30%;
@@ -1694,10 +1707,10 @@ kbd {
line-height: 1.4;
}
- /* Pas de bottom sheet sur mobile. La photo du device reste visible en haut
- et le panneau prend le reste de la hauteur : seule sa liste défile. C'est
- ce qui a permis de supprimer le mini-schéma du clavier, le piège à focus
- et le scrim — le vrai clavier est là, et il reste cliquable. */
+ /* No bottom sheet on mobile. The device photo stays visible at the top and the
+ panel takes the rest of the height: only its list scrolls. That is what
+ allowed removing the mini keyboard diagram, the focus trap and the scrim —
+ the real keyboard is right there, and it stays clickable. */
.app-shell.is-panel-open {
display: flex;
height: 100dvh;
@@ -1715,7 +1728,7 @@ kbd {
padding: 56px 14px 0;
}
- /* Tout ce qui n'aide pas à choisir une touche laisse la place au panneau. */
+ /* Anything that does not help pick a key gives way to the panel. */
.app-shell.is-panel-open .hero-copy,
.app-shell.is-panel-open .configure-button,
.app-shell.is-panel-open .generate-button,
@@ -1725,9 +1738,8 @@ kbd {
display: none;
}
- /* La boîte est désormais recadrée sur le device, donc plus de vide à
- récupérer par une marge négative : la largeur du stage est directement
- celle du clavier. */
+ /* The box is now cropped to the device, so there is no empty space left to
+ reclaim with a negative margin: the stage width is the keyboard's width. */
.app-shell.is-panel-open .device-stage {
width: min(285px, 78vw);
margin: 0 auto;
@@ -1833,7 +1845,7 @@ kbd {
.mapping-preview button,
.brand-mark,
.mapping-dialog {
- background: #fffaf6;
+ background: var(--solid-glass-background);
backdrop-filter: none;
}
@@ -1843,12 +1855,9 @@ kbd {
}
.toast {
+ background: var(--solid-toast-background);
backdrop-filter: none;
}
-
- .toast {
- background: #453a36;
- }
}
/* Dark theme is driven by data-theme on (set by the inline script in
@@ -1869,6 +1878,8 @@ kbd {
--line-strong: rgba(240, 225, 214, 0.28);
--shadow: rgba(0, 0, 0, 0.45);
--glass-border: rgba(255, 255, 255, 0.24);
+ --solid-glass-background: #2e231d;
+ --solid-toast-background: #362a23;
}
body {
@@ -2225,21 +2236,4 @@ kbd {
inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
- @media (prefers-reduced-transparency: reduce) {
- .local-status,
- .language-select,
- .theme-toggle,
- .configure-button,
- .mapping-preview button,
- .brand-mark,
- .mapping-dialog {
- background: #2e231d;
- backdrop-filter: none;
- }
-
- .toast {
- background: #362a23;
- backdrop-filter: none;
- }
- }
}
diff --git a/prototype/tests/app-render.test.mjs b/prototype/tests/app-render.test.mjs
new file mode 100644
index 0000000..ad79a8e
--- /dev/null
+++ b/prototype/tests/app-render.test.mjs
@@ -0,0 +1,119 @@
+import assert from "node:assert/strict";
+import { after, before, test } from "node:test";
+import React from "../node_modules/react/index.js";
+import { renderToString } from "../node_modules/react-dom/server.node.js";
+import { createServer } from "../node_modules/vite/dist/node/index.js";
+import { LOCALES } from "../src/i18n/index.js";
+import { createProfileSession } from "../src/profile-session.js";
+
+const values = new Map();
+const storage = {
+ getItem(key) {
+ return values.get(key) ?? null;
+ },
+ setItem(key, value) {
+ values.set(key, value);
+ },
+};
+
+let server;
+let App;
+let createJoystickDialGeometry;
+let presenter;
+let ProfileExportPanel;
+
+before(async () => {
+ globalThis.window = { localStorage: storage };
+ server = await createServer({
+ root: new URL("..", import.meta.url).pathname,
+ server: { middlewareMode: true },
+ appType: "custom",
+ logLevel: "error",
+ });
+ ({ App } = await server.ssrLoadModule("/src/App.jsx"));
+ ({ createJoystickDialGeometry } = await server.ssrLoadModule(
+ "/src/components/JoystickDial.jsx",
+ ));
+ presenter = await server.ssrLoadModule("/src/configurator-presenter.js");
+ ({ ProfileExportPanel } = await server.ssrLoadModule(
+ "/src/components/ProfileExportPanel.jsx",
+ ));
+});
+
+after(async () => {
+ await server?.close();
+ delete globalThis.window;
+});
+
+test("renders the complete configurator from the default state", () => {
+ values.clear();
+ const html = renderToString(React.createElement(App));
+
+ assert.match(html, /Codex Micro/);
+ assert.ok(html.includes(LOCALES.en.hero.subtitle));
+ assert.equal((html.match(/class="key-hotspot /g) ?? []).length, 15);
+ assert.match(html, /class="mapping-dialog "/);
+ assert.match(html, /aria-hidden="true"/);
+});
+
+test("renders a saved locale while malformed mapping storage fails closed", () => {
+ values.clear();
+ values.set("codex-micro-locale", "fr");
+ values.set("codex-micro-mapping", "not-json");
+
+ const html = renderToString(React.createElement(App));
+ assert.ok(html.includes(LOCALES.fr.hero.subtitle));
+ assert.ok(html.includes(LOCALES.fr.buttons.configureKeys));
+});
+
+test("precomputes stable joystick paths for supported direction counts", () => {
+ const four = createJoystickDialGeometry(4);
+ const eight = createJoystickDialGeometry(8);
+ assert.equal(four.sectors.length, 4);
+ assert.equal(eight.sectors.length, 8);
+ assert.match(four.close.path, /^M /);
+ assert.equal(four.close.centroid.length, 2);
+ assert.notEqual(four.sectors[0].path, four.sectors[1].path);
+});
+
+test("presents catalogue, custom, joystick, and fallback entries consistently", () => {
+ const t = (key, values) => values ? `${key}:${JSON.stringify(values)}` : key;
+ const custom = { type: "custom", keys: ["Command", "Shift", "K"] };
+ const joystick = { directions: 4, sectors: ["none", "none", "none", "none"] };
+ assert.equal(presenter.entryFor({ "key-1": "voice" }, "key-1"), "voice");
+ assert.equal(presenter.entryFor({}, "key-1"), "none");
+ assert.equal(presenter.entryExportLabel(custom), "⌘⇧K");
+ assert.equal(presenter.entryExportLabel(joystick), "4 DIR");
+ assert.equal(presenter.entryExportLabel("unknown"), "NONE");
+ assert.equal(presenter.entryLabel(custom, t), "actions.custom.label");
+ assert.equal(presenter.entryLabel(joystick, t), "actions.joystickCustom.label");
+ assert.equal(presenter.entryShortcut(custom, t), "⌘⇧K");
+ assert.match(presenter.entryShortcut(joystick, t), /^actions\.joystickCustom\.shortcut:/);
+ assert.ok(presenter.entryIcon(custom));
+ assert.ok(presenter.entryIcon(joystick));
+ assert.ok(presenter.entryIcon("unknown"));
+ const control = { id: "key-1", shortLabel: "C1" };
+ assert.equal(presenter.controlLabel(control, t), "controls.key-1");
+ assert.equal(presenter.controlBadgeLabel(control, "none"), "C1");
+ assert.equal(presenter.controlBadgeLabel(control, "voice"), "VOICE");
+});
+
+test("renders the deferred export workflow independently", () => {
+ const profile = createProfileSession();
+ const ref = { current: null };
+ const html = renderToString(
+ React.createElement(ProfileExportPanel, {
+ t: (key) => key,
+ refs: { loader: ref, profileInput: ref, review: ref },
+ profile,
+ onLoadSourceProfile() {},
+ onResolveMappingConflict() {},
+ onAppSenseChange() {},
+ onRunReview() {},
+ onDownloadReview() {},
+ }),
+ );
+ assert.match(html, /wizard-step-1-title/);
+ assert.match(html, /wizard-step-3-title/);
+ assert.match(html, /review.needProfile/);
+});
diff --git a/prototype/tests/architecture.test.mjs b/prototype/tests/architecture.test.mjs
new file mode 100644
index 0000000..06e1686
--- /dev/null
+++ b/prototype/tests/architecture.test.mjs
@@ -0,0 +1,75 @@
+import assert from "node:assert/strict";
+import { readdir, readFile } from "node:fs/promises";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+const sourceRoot = fileURLToPath(new URL("../src", import.meta.url));
+const sourceExtensions = new Set([".js", ".jsx"]);
+
+async function sourceFiles(directory = sourceRoot) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const nested = await Promise.all(entries.map(async (entry) => {
+ const target = path.join(directory, entry.name);
+ if (entry.isDirectory()) return sourceFiles(target);
+ return sourceExtensions.has(path.extname(entry.name)) ? [target] : [];
+ }));
+ return nested.flat();
+}
+
+function localDependencies(file, source) {
+ const dependencies = [];
+ const imports = source.matchAll(/(?:from\s+|import\s*\()(["'])(\.\.?\/[^"']+)\1/g);
+ for (const match of imports) {
+ const resolved = path.resolve(path.dirname(file), match[2]);
+ dependencies.push(resolved);
+ }
+ return dependencies;
+}
+
+test("the configurator source graph stays acyclic", async () => {
+ const files = await sourceFiles();
+ const knownFiles = new Set(files);
+ const graph = new Map();
+
+ for (const file of files) {
+ const source = await readFile(file, "utf8");
+ graph.set(
+ file,
+ localDependencies(file, source).filter((dependency) => knownFiles.has(dependency)),
+ );
+ }
+
+ const visiting = new Set();
+ const visited = new Set();
+ function visit(file, ancestry = []) {
+ if (visiting.has(file)) {
+ assert.fail(`Circular dependency: ${[...ancestry, file].map(path.basename).join(" -> ")}`);
+ }
+ if (visited.has(file)) return;
+ visiting.add(file);
+ for (const dependency of graph.get(file) ?? []) visit(dependency, [...ancestry, file]);
+ visiting.delete(file);
+ visited.add(file);
+ }
+
+ for (const file of files) visit(file);
+ assert.equal(visited.size, files.length);
+});
+
+test("hardware profile mutation remains behind the profile workflow boundary", async () => {
+ const files = await sourceFiles();
+ const forbiddenOutsideWorkflow =
+ /\b(?:addClaudeLayer|buildInputProfile|deriveMappingFromProfile|inspectInputProfile)\b/;
+ const violations = [];
+
+ for (const file of files) {
+ if (path.basename(file) === "profile-workflow.js") continue;
+ const source = await readFile(file, "utf8");
+ if (forbiddenOutsideWorkflow.test(source)) {
+ violations.push(path.relative(sourceRoot, file));
+ }
+ }
+
+ assert.deepEqual(violations, []);
+});
diff --git a/prototype/tests/configurator-state.test.mjs b/prototype/tests/configurator-state.test.mjs
new file mode 100644
index 0000000..dd7692b
--- /dev/null
+++ b/prototype/tests/configurator-state.test.mjs
@@ -0,0 +1,223 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { ACTION_DEFINITIONS, DEFAULT_MAPPING } from "../../shared/input-profile.mjs";
+import {
+ DEFAULT_CUSTOM,
+ MAPPING_STORAGE_KEY,
+ assignMappingEntry,
+ assertKeyActionCatalog,
+ entryFingerprint,
+ formatCustomKeys,
+ indexDuplicateKeyControls,
+ isCustom,
+ isCustomJoystick,
+ isValidEntry,
+ loadStoredState,
+ makeCustomJoystick,
+ mappingsEqual,
+ parseOptionalNonNegativeInteger,
+ replaceShortcutFinalKey,
+ saveStoredState,
+ setJoystickMappingMode,
+ toggleShortcutModifier,
+} from "../src/configurator-state.js";
+
+function storageFor(value) {
+ return {
+ getItem(key) {
+ assert.equal(key, MAPPING_STORAGE_KEY);
+ return value;
+ },
+ };
+}
+
+test("loads only valid stored assignments and restores every missing default", () => {
+ const state = loadStoredState(
+ storageFor(
+ JSON.stringify({
+ mapping: {
+ "key-1": "settings",
+ "key-2": { type: "custom", keys: ["Command", "K"] },
+ wheel: "lines",
+ joystick: {
+ directions: 4,
+ sectors: ["newSession", "voice", "diff", "none"],
+ },
+ unknown: "settings",
+ },
+ }),
+ ),
+ );
+
+ assert.equal(state.mapping["key-1"], "settings");
+ assert.deepEqual(state.mapping["key-2"], { type: "custom", keys: ["Command", "K"] });
+ assert.equal(state.mapping.wheel, "lines");
+ assert.equal(state.mapping.joystick.directions, 4);
+ assert.equal("unknown" in state.mapping, false);
+ assert.equal(state.mapping["key-13"], DEFAULT_MAPPING["key-13"]);
+});
+
+test("fails closed for unavailable, malformed, or structurally invalid storage", () => {
+ assert.deepEqual(loadStoredState(storageFor(null)), { mapping: DEFAULT_MAPPING });
+ assert.deepEqual(loadStoredState(storageFor("not-json")), { mapping: DEFAULT_MAPPING });
+ assert.deepEqual(loadStoredState(storageFor(JSON.stringify({ mapping: [] }))), {
+ mapping: DEFAULT_MAPPING,
+ });
+ assert.deepEqual(
+ loadStoredState({
+ getItem() {
+ throw new Error("storage blocked");
+ },
+ }),
+ { mapping: DEFAULT_MAPPING },
+ );
+});
+
+test("validates key, wheel, and joystick entries from shared sources of truth", () => {
+ assert.equal(isValidEntry("key-1", "sendInDuplicateSession"), true);
+ assert.equal(isValidEntry("key-1", DEFAULT_CUSTOM), true);
+ assert.equal(isValidEntry("key-1", { type: "custom", keys: ["Command", 1] }), false);
+ assert.equal(isValidEntry("key-1", { type: "custom", keys: [] }), false);
+ assert.equal(isValidEntry("key-1", { type: "custom", keys: ["Command", "Enter"] }), false);
+ assert.equal(
+ isValidEntry("key-1", { type: "custom", keys: ["Command", "Command", "K"] }),
+ false,
+ );
+ assert.equal(isValidEntry("key-99", "settings"), false);
+ assert.equal(isValidEntry("wheel", "effort"), true);
+ assert.equal(isValidEntry("wheel", "navigation"), false);
+ assert.equal(isValidEntry("wheel", "toString"), false);
+ assert.equal(isValidEntry("joystick", "navigation"), true);
+ assert.equal(
+ isValidEntry("joystick", {
+ directions: 8,
+ sectors: new Array(8).fill("voice"),
+ }),
+ true,
+ );
+ assert.equal(
+ isValidEntry("joystick", { directions: 6, sectors: new Array(6).fill("voice") }),
+ false,
+ );
+});
+
+test("fails fast when the UI catalogue drifts from shared key actions", () => {
+ const allActions = Object.fromEntries(
+ Object.keys(ACTION_DEFINITIONS).map((id) => [id, { controlTypes: ["key"] }]),
+ );
+ assert.doesNotThrow(() => assertKeyActionCatalog(allActions));
+ delete allActions.voice;
+ assert.throws(() => assertKeyActionCatalog(allActions), /missing key actions: voice/);
+});
+
+test("builds joystick mappings and formats custom shortcuts without mutation", () => {
+ const previous = ["voice", "diff"];
+ const joystick = makeCustomJoystick(4, previous);
+ assert.deepEqual(joystick, {
+ directions: 4,
+ sectors: ["voice", "diff", "none", "none"],
+ });
+ assert.deepEqual(previous, ["voice", "diff"]);
+ assert.equal(isCustomJoystick(joystick), true);
+ assert.equal(isCustom(DEFAULT_CUSTOM), true);
+ assert.equal(formatCustomKeys(["Command", "Shift", "BracketLeft"]), "⌘⇧[");
+});
+
+test("compares normalized mappings and indexes duplicate key assignments once", () => {
+ assert.equal(mappingsEqual({ "key-1": "voice" }, { "key-1": "voice", "key-2": "none" }), true);
+ assert.equal(
+ mappingsEqual(
+ { "key-1": { type: "custom", keys: ["Command", "K"] } },
+ { "key-1": { keys: ["Command", "K"], type: "custom" } },
+ ),
+ true,
+ );
+ assert.equal(
+ mappingsEqual(
+ { joystick: { directions: 4, sectors: ["voice", "diff", "none", "none"] } },
+ { joystick: { sectors: ["voice", "diff", "none", "none"], directions: 4 } },
+ ),
+ true,
+ );
+ assert.equal(mappingsEqual({ "key-1": "voice" }, { "key-1": "diff" }), false);
+ assert.equal(entryFingerprint(undefined), JSON.stringify("none"));
+
+ const controls = [
+ { id: "key-1", type: "key" },
+ { id: "key-2", type: "key" },
+ { id: "key-3", type: "key" },
+ { id: "wheel", type: "dial" },
+ ];
+ const index = indexDuplicateKeyControls(
+ controls,
+ { "key-1": "voice", "key-2": "voice", "key-3": "voice", wheel: "effort" },
+ "key-1",
+ );
+ assert.equal(index.get(entryFingerprint("voice")).id, "key-2");
+ assert.equal(index.has(entryFingerprint("effort")), false);
+});
+
+test("parses optional AppSense ids and preserves the stable error code", () => {
+ assert.equal(parseOptionalNonNegativeInteger(""), undefined);
+ assert.equal(parseOptionalNonNegativeInteger(" 12 "), 12);
+ assert.equal(parseOptionalNonNegativeInteger(0), 0);
+ for (const value of ["-1", "1.5", "not-a-number"]) {
+ assert.throws(() => parseOptionalNonNegativeInteger(value), {
+ code: "INVALID_APPSENSE_ID",
+ });
+ }
+});
+
+test("persists mappings defensively", () => {
+ const writes = [];
+ assert.equal(
+ saveStoredState({ "key-1": "voice" }, { setItem: (...args) => writes.push(args) }),
+ true,
+ );
+ assert.deepEqual(writes, [
+ [MAPPING_STORAGE_KEY, JSON.stringify({ mapping: { "key-1": "voice" } })],
+ ]);
+ assert.equal(
+ saveStoredState({}, { setItem: () => { throw new Error("quota"); } }),
+ false,
+ );
+});
+
+test("updates direct and joystick assignments without mutating previous mappings", () => {
+ const initial = {
+ ...DEFAULT_MAPPING,
+ joystick: { directions: 4, sectors: ["voice", "none", "none", "none"] },
+ };
+ const direct = assignMappingEntry(initial, "key-1", null, "settings");
+ assert.equal(direct["key-1"], "settings");
+ assert.equal(initial["key-1"], DEFAULT_MAPPING["key-1"]);
+
+ const joystick = assignMappingEntry(initial, "joystick", 1, "diff");
+ assert.deepEqual(joystick.joystick.sectors, ["voice", "diff", "none", "none"]);
+ assert.deepEqual(initial.joystick.sectors, ["voice", "none", "none", "none"]);
+ assert.equal(assignMappingEntry(initial, "joystick", 9, "diff"), initial);
+ assert.equal(
+ assignMappingEntry({ ...initial, joystick: "navigation" }, "joystick", 0, "diff").joystick,
+ "navigation",
+ );
+});
+
+test("changes joystick modes and custom shortcuts through pure transformations", () => {
+ const custom = {
+ ...DEFAULT_MAPPING,
+ joystick: { directions: 4, sectors: ["voice", "diff", "none", "none"] },
+ };
+ assert.equal(setJoystickMappingMode(custom, "navigation").joystick, "navigation");
+ assert.equal(setJoystickMappingMode(custom, 6), custom);
+ assert.deepEqual(setJoystickMappingMode(custom, 8).joystick.sectors, [
+ "voice", "diff", "none", "none", "none", "none", "none", "none",
+ ]);
+
+ const keys = ["Command", "K"];
+ assert.deepEqual(toggleShortcutModifier(keys, "Shift"), ["Command", "Shift", "K"]);
+ assert.deepEqual(toggleShortcutModifier(["Command", "Shift", "K"], "Command"), ["Shift", "K"]);
+ assert.equal(toggleShortcutModifier(keys, "unknown"), keys);
+ assert.deepEqual(replaceShortcutFinalKey(keys, "F1"), ["Command", "F1"]);
+ assert.equal(replaceShortcutFinalKey(keys, "Enter"), keys);
+ assert.equal(replaceShortcutFinalKey(keys, 1), keys);
+});
diff --git a/prototype/tests/i18n.test.mjs b/prototype/tests/i18n.test.mjs
new file mode 100644
index 0000000..a9fcb08
--- /dev/null
+++ b/prototype/tests/i18n.test.mjs
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ LOCALES,
+ createTranslator,
+ detectLocale,
+ saveLocale,
+} from "../src/i18n/index.js";
+
+test("detects a supported saved locale and otherwise falls back to English", () => {
+ assert.equal(detectLocale({ getItem: () => "de" }), "de");
+ assert.equal(detectLocale({ getItem: () => "unknown" }), "en");
+ assert.equal(detectLocale({ getItem: () => null }), "en");
+ assert.equal(
+ detectLocale({
+ getItem() {
+ throw new Error("storage unavailable");
+ },
+ }),
+ "en",
+ );
+});
+
+test("saves the preference when storage is available and ignores storage failures", () => {
+ const writes = [];
+ saveLocale("es", { setItem: (...args) => writes.push(args) });
+ assert.deepEqual(writes, [["codex-micro-locale", "es"]]);
+ assert.doesNotThrow(() =>
+ saveLocale("fr", {
+ setItem() {
+ throw new Error("private mode");
+ },
+ }),
+ );
+});
+
+test("translates nested keys, interpolates values, and keeps deterministic fallbacks", () => {
+ const english = createTranslator("en");
+ assert.equal(english("meta.title"), LOCALES.en.meta.title);
+ assert.equal(
+ english("loader.meta", {
+ file: "profile.json",
+ layer: "Claude",
+ appSense: "linked",
+ }),
+ LOCALES.en.loader.meta
+ .replaceAll("{file}", "profile.json")
+ .replaceAll("{layer}", "Claude")
+ .replaceAll("{appSense}", "linked"),
+ );
+
+ const unknownLocale = createTranslator("xx");
+ assert.equal(unknownLocale("meta.title"), LOCALES.fr.meta.title);
+ assert.equal(english("missing.translation.key"), "missing.translation.key");
+});
diff --git a/prototype/tests/presentation.test.mjs b/prototype/tests/presentation.test.mjs
new file mode 100644
index 0000000..260bd4a
--- /dev/null
+++ b/prototype/tests/presentation.test.mjs
@@ -0,0 +1,13 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { detectTheme, nextTheme, THEME_STORAGE_KEY } from "../src/hooks/use-theme.js";
+
+test("detects and cycles themes with storage failures isolated", () => {
+ assert.equal(detectTheme({ getItem: (key) => key === THEME_STORAGE_KEY ? "dark" : null }), "dark");
+ assert.equal(detectTheme({ getItem: () => "unknown" }), "auto");
+ assert.equal(detectTheme({ getItem: () => { throw new Error("blocked"); } }), "auto");
+ assert.equal(nextTheme("auto"), "light");
+ assert.equal(nextTheme("light"), "dark");
+ assert.equal(nextTheme("dark"), "auto");
+ assert.equal(nextTheme("unknown"), "auto");
+});
diff --git a/prototype/tests/profile-session.test.mjs b/prototype/tests/profile-session.test.mjs
new file mode 100644
index 0000000..f2ca5a7
--- /dev/null
+++ b/prototype/tests/profile-session.test.mjs
@@ -0,0 +1,131 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { DEFAULT_MAPPING } from "../../shared/input-profile.mjs";
+import { sourceProfile } from "../../tests/helpers/input-profile-fixture.mjs";
+import {
+ createProfileSession,
+ profileSessionReducer,
+} from "../src/profile-session.js";
+import {
+ createProfileReview,
+ describeProfileError,
+ prepareImportedProfile,
+ sha256Hex,
+} from "../src/profile-workflow.js";
+
+const t = (key) => `translated:${key}`;
+
+test("keeps profile workflow transitions atomic and invalidates stale reviews", () => {
+ const initial = createProfileSession();
+ const source = sourceProfile();
+ const loaded = profileSessionReducer(initial, {
+ type: "profile-loaded",
+ payload: {
+ source,
+ fileName: "profile.json",
+ info: { layerName: "Claude" },
+ layerCreated: null,
+ mappingConflict: { mapping: DEFAULT_MAPPING },
+ },
+ });
+ assert.equal(loaded.source, source);
+ assert.equal(loaded.fileName, "profile.json");
+
+ const reviewed = profileSessionReducer(loaded, {
+ type: "review-ready",
+ review: { json: "{}" },
+ });
+ const changed = profileSessionReducer(reviewed, {
+ type: "appsense-changed",
+ field: "claude",
+ value: "7",
+ });
+ assert.equal(changed.appSenseIds.claude, "7");
+ assert.equal(changed.review, null);
+ assert.equal(profileSessionReducer(changed, { type: "review-invalidated" }), changed);
+ assert.throws(
+ () => profileSessionReducer(changed, {
+ type: "appsense-changed",
+ field: "unknown",
+ value: "1",
+ }),
+ /Unknown AppSense field/,
+ );
+
+ const resolved = profileSessionReducer(changed, { type: "conflict-resolved" });
+ assert.equal(resolved.mappingConflict, null);
+ const failed = profileSessionReducer(resolved, {
+ type: "review-failed",
+ error: { message: "failed" },
+ });
+ assert.equal(failed.error.message, "failed");
+ assert.equal(
+ profileSessionReducer(failed, { type: "profile-failed", error: { message: "bad file" } })
+ .error.message,
+ "bad file",
+ );
+ assert.throws(() => profileSessionReducer(initial, { type: "unknown" }), /Unknown profile/);
+});
+
+test("prepares existing and newly synthesized Claude layers", () => {
+ const existing = prepareImportedProfile(sourceProfile());
+ assert.equal(existing.info.layerName, "Claude");
+ assert.equal(existing.layerCreated, null);
+ assert.ok(existing.derived.assigned > 0);
+
+ const missing = sourceProfile();
+ missing.profile.layers[1].name = "Template";
+ const synthesized = prepareImportedProfile(missing);
+ assert.equal(synthesized.info.layerName, "Claude");
+ assert.equal(synthesized.layerCreated.templateName, "Template");
+ assert.equal(synthesized.source.profile.layers.length, 3);
+});
+
+test("hashes and builds a review with injectable browser crypto", async () => {
+ const crypto = {
+ subtle: {
+ async digest(algorithm, bytes) {
+ assert.equal(algorithm, "SHA-256");
+ assert.ok(bytes.length > 0);
+ return Uint8Array.from([0, 15, 255]).buffer;
+ },
+ },
+ };
+ assert.equal(await sha256Hex("profile", crypto), "000fff");
+ assert.equal(await sha256Hex("profile", null), "");
+ assert.equal(
+ await sha256Hex("profile", { subtle: { digest: async () => { throw new Error("blocked"); } } }),
+ "",
+ );
+
+ const review = await createProfileReview(
+ sourceProfile(),
+ DEFAULT_MAPPING,
+ { claude: "", base: "" },
+ crypto,
+ );
+ assert.match(review.json, /"Claude macOS"/);
+ assert.equal(review.sha, "000fff");
+ assert.equal(review.report.nativeLayerPreserved, true);
+});
+
+test("normalizes syntax, coded, generic, and unknown profile errors", () => {
+ assert.deepEqual(describeProfileError(new SyntaxError("bad"), t), {
+ message: "translated:errors.invalidJson",
+ code: null,
+ });
+ const coded = new Error("wrong");
+ coded.code = "WRONG_DEVICE";
+ assert.deepEqual(describeProfileError(coded, t), {
+ message: "translated:errors.WRONG_DEVICE",
+ code: "WRONG_DEVICE",
+ });
+ assert.deepEqual(describeProfileError(new Error("plain"), t), {
+ message: "plain",
+ code: null,
+ });
+ assert.deepEqual(describeProfileError(null, t), {
+ message: "translated:errors.invalidFile",
+ code: null,
+ });
+});
diff --git a/scripts/build-input-profile.mjs b/scripts/build-input-profile.mjs
index 72f8635..1639a3a 100644
--- a/scripts/build-input-profile.mjs
+++ b/scripts/build-input-profile.mjs
@@ -15,7 +15,7 @@ for (const argument of process.argv.slice(2)) {
}
const value = Number(match[2]);
if (!Number.isInteger(value) || value < 0) {
- console.error(`Valeur invalide pour --${match[1]} : ${match[2]}`);
+ console.error(`Invalid value for --${match[1]}: ${match[2]}`);
process.exit(1);
}
options[match[1] === "app-sense-id" ? "appSenseId" : "baseLayerAppSenseId"] = value;
@@ -52,13 +52,13 @@ await writeFile(outputPath, `${JSON.stringify(profile, null, 2)}\n`, {
flag: "wx",
});
-console.log(`OK: profil Input créé dans ${outputPath}`);
+console.log(`OK: Input profile written to ${outputPath}`);
console.log(
- `Layer ${report.layerName}; ${report.preservedLayers} layer(s) préservé(s); AppSense préservé`,
+ `Layer ${report.layerName}; ${report.preservedLayers} layer(s) preserved; AppSense preserved`,
);
console.log(
- `Lien AppSense du layer Claude : ${report.appSenseId ?? "aucun"}` +
- `${report.appSenseForced ? " (forcé)" : ""}`,
+ `Claude layer AppSense link: ${report.appSenseId ?? "none"}` +
+ `${report.appSenseForced ? " (forced)" : ""}`,
);
if (report.baseLayerAppSenseId !== null) {
console.log(
diff --git a/scripts/configure.mjs b/scripts/configure.mjs
index 484fcf0..40ad753 100644
--- a/scripts/configure.mjs
+++ b/scripts/configure.mjs
@@ -6,42 +6,42 @@ import { ensureGuiDependencies } from "./lib/gui-dependencies.mjs";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const guiDirectory = resolve(repositoryRoot, "prototype");
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
+const args = process.argv.slice(2);
-try {
- ensureGuiDependencies(guiDirectory);
-} catch (error) {
- console.error(error.message);
- process.exit(error.exitCode ?? 1);
-}
+if (args.includes("--help") || args.includes("-h")) {
+ console.log(`Usage: npm run configure -- [vite options]
-console.log("Ouverture du configurateur Codex Micro…");
+Prepares the locked GUI dependencies, starts the local configurator, and opens it
+in the default browser. Extra options are passed to Vite.`);
+} else {
+ try {
+ ensureGuiDependencies(guiDirectory);
+ } catch (error) {
+ console.error(error.message);
+ process.exit(error.exitCode ?? 1);
+ }
-const gui = spawn(
- npmCommand,
- [
- "run",
- "dev",
- "--",
- "--host",
- "127.0.0.1",
- "--open",
- ...process.argv.slice(2),
- ],
- {
- cwd: guiDirectory,
- stdio: "inherit",
- },
-);
+ console.log("Ouverture du configurateur Codex Micro…");
-gui.on("error", (error) => {
- console.error(`Impossible de lancer l’interface : ${error.message}`);
- process.exit(1);
-});
+ const gui = spawn(
+ npmCommand,
+ ["run", "dev", "--", "--host", "127.0.0.1", "--open", ...args],
+ {
+ cwd: guiDirectory,
+ stdio: "inherit",
+ },
+ );
-gui.on("exit", (code, signal) => {
- if (signal) {
- process.kill(process.pid, signal);
- return;
- }
- process.exit(code ?? 0);
-});
+ gui.on("error", (error) => {
+ console.error(`Could not start the interface: ${error.message}`);
+ process.exit(1);
+ });
+
+ gui.on("exit", (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+ process.exit(code ?? 0);
+ });
+}
diff --git a/scripts/enable-agent-keys.mjs b/scripts/enable-agent-keys.mjs
index 844d356..d97f5a6 100644
--- a/scripts/enable-agent-keys.mjs
+++ b/scripts/enable-agent-keys.mjs
@@ -1,34 +1,34 @@
#!/usr/bin/env node
-// Assigne les keycodes Agent natifs aux six positions vides du layer `Claude`,
-// ce qui **débloque l'éclairage par thread sur ce layer**.
+// Assigns the native Agent keycodes to the six empty positions of the `Claude`
+// layer, which **unlocks per-thread lighting on that layer**.
//
-// Le fait, confirmé sur matériel : l'éclairage par thread (`v.oai.thstatus`) ne
-// rend que sur les touches dont le keycode est `KV_OAI_AG00` à `KV_OAI_AG05`. Le
-// prédicat du firmware est le keycode, pas l'index du layer : c'est le keycode qui
-// dit au firmware quelle touche physique est l'emplacement N. Sur un layer où ces
-// positions valent `KC_NONE`, il n'y a aucun emplacement à peindre, et les
-// écritures sont acquittées sans effet visible.
+// The fact, confirmed on hardware: per-thread lighting (`v.oai.thstatus`) only
+// renders on keys whose keycode is `KV_OAI_AG00` through `KV_OAI_AG05`. The
+// firmware's predicate is the keycode, not the layer index: the keycode is what
+// tells the firmware which physical key is slot N. On a layer where those
+// positions are `KC_NONE`, there is no slot to paint, and the writes are
+// acknowledged with no visible effect.
//
-// L'indice se lisait dans le bundle d'Input, où le layer natif définit exactement
-// `base[0] = [AG00, AG01]` et `base[1] = [AG02..AG05]`, soit deux puis quatre
-// touches — la géométrie confirmée à l'œil sur ce matériel. Ces keycodes
-// n'apparaissent qu'une fois chacun dans l'`app.asar` : Input ne les propose pas
-// dans son sélecteur, d'où ce script.
+// The clue was in Input's bundle, where the native layer defines exactly
+// `base[0] = [AG00, AG01]` and `base[1] = [AG02..AG05]`, so two keys then four —
+// the geometry confirmed by eye on this hardware. These keycodes appear only
+// once each in the `app.asar`: Input does not offer them in its picker, hence
+// this script.
//
-// Aucun raccourci Claude n'est perdu : les six positions sont `no-action` dans le
-// preset Claude, et les raccourcis vivent sur la rangée suivante.
+// No Claude shortcut is lost: the six positions are `no-action` in the Claude
+// preset, and the shortcuts live on the next row.
//
-// Le coût est ailleurs, et il faut le savoir avant d'importer : ces keycodes font
-// émettre au firmware une notification `v.oai.hid`, à laquelle **l'app ChatGPT
-// réagit en changeant de thread Codex**. Elle contend donc les deux moitiés de la
-// fonction — elle réécrit les LED toutes les 35 à 40 s et elle intercepte les
-// appuis. Quitter l'app ChatGPT résout les deux d'un coup, et c'est la condition
-// d'usage réelle.
+// The cost is elsewhere, and it is worth knowing before importing: these
+// keycodes make the firmware emit a `v.oai.hid` notification, which **the
+// ChatGPT app reacts to by switching Codex thread**. It therefore contends for
+// both halves of the feature — it rewrites the LEDs every 35 to 40s and it
+// intercepts the presses. Quitting the ChatGPT app solves both at once, and
+// that is the real condition of use.
//
-// Ce script ne touche jamais le layer d'index 0, n'écrit pas dans le stockage
-// d'Input et n'écrit pas sur le périphérique. Il produit un fichier à importer
-// par le flux officiel **Import Profile**, et `--revert` défait l'opération.
+// This script never touches the layer at index 0, does not write into Input's
+// storage and does not write to the device. It produces a file to import through
+// the official **Import Profile** flow, and `--revert` undoes the operation.
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
@@ -36,9 +36,9 @@ import os from "node:os";
import path from "node:path";
import { redactHome } from "./lib/input-export.mjs";
-// Relevés dans /Applications/Input.app/Contents/Resources/app.asar, définition
-// câblée du layer Codex natif. Ils n'apparaissent qu'une seule fois dans le
-// bundle : Input ne les propose pas dans son sélecteur de touches, d'où ce script.
+// Read from /Applications/Input.app/Contents/Resources/app.asar, in the
+// hard-wired definition of the native Codex layer. They appear only once in the
+// bundle: Input does not offer them in its key picker, hence this script.
const AGENT_KEYCODES = Object.freeze([
["KV_OAI_AG00", "KV_OAI_AG01"],
["KV_OAI_AG02", "KV_OAI_AG03", "KV_OAI_AG04", "KV_OAI_AG05"],
@@ -47,24 +47,22 @@ const AGENT_KEYCODES = Object.freeze([
const CLAUDE_LAYER_NAME = "Claude";
function usage() {
- return `Usage: node scripts/enable-agent-keys.mjs [sortie.json] [--revert]
+ return `Usage: node scripts/enable-agent-keys.mjs [output.json] [--revert]
-Assigne les keycodes Agent natifs aux six positions vides du layer Claude, pour
-éprouver le rendu de l'éclairage par thread hors du layer Codex.
+Assigns the native Agent keycodes to the six empty positions of the Claude
+layer, so per-thread lighting can be exercised outside the Codex layer.
- --revert remet KC_NONE sur les six positions
+ --revert put KC_NONE back on the six positions
-Sans destination, le fichier est écrit dans ~/Downloads, là où Input ouvre sa
-boîte de dialogue Import Profile, en conservant le suffixe « -profile.json »
-qu'Input attend.
+With no destination, the file is written to ~/Downloads, where Input opens its
+Import Profile dialog, keeping the "-profile.json" suffix Input expects.
-Le layer d'index 0 n'est jamais modifié.
+The layer at index 0 is never modified.
`;
}
-// Input ouvre Import Profile sur ~/Downloads et reconnaît le suffixe
-// « -profile.json » de ses propres exports : la destination par défaut respecte
-// les deux.
+// Input opens Import Profile on ~/Downloads and recognises the "-profile.json"
+// suffix of its own exports: the default destination honours both.
function defaultDestination(source, { revert }) {
const suffix = revert ? "Revert" : "AgentKeys";
const base = path.basename(source).replace(/(-profile)?\.json$/i, "");
@@ -78,22 +76,22 @@ function assert(condition, message) {
}
}
-// Les six cellules visées, et rien d'autre : deux rangées, aux longueurs
-// attendues. Une disposition inattendue est refusée plutôt qu'interprétée.
+// The six targeted cells, and nothing else: two rows, at the expected lengths.
+// An unexpected layout is refused rather than interpreted.
function patchAgentRows(layer, { revert }) {
const changes = [];
AGENT_KEYCODES.forEach((row, rowIndex) => {
const cells = layer.layout?.base?.[rowIndex];
assert(
Array.isArray(cells) && cells.length === row.length,
- `Disposition inattendue pour la rangée ${rowIndex} : ${row.length} cellules attendues.`,
+ `Unexpected layout for row ${rowIndex}: ${row.length} cells expected.`,
);
row.forEach((keycode, columnIndex) => {
const target = revert ? "KC_NONE" : keycode;
const cell = cells[columnIndex];
assert(
cell && typeof cell === "object",
- `Cellule ${rowIndex}/${columnIndex} illisible dans le layer Claude.`,
+ `Cell ${rowIndex}/${columnIndex} is unreadable in the Claude layer.`,
);
if (cell.keycode !== target) {
changes.push(` base[${rowIndex}][${columnIndex}] : ${cell.keycode} → ${target}`);
@@ -115,35 +113,35 @@ async function main(argv) {
const raw = JSON.parse(await fs.readFile(source, "utf8"));
const layers = raw.profile?.layers;
- assert(Array.isArray(layers) && layers.length > 1, "Cet export ne contient pas de liste de layers exploitable.");
+ assert(Array.isArray(layers) && layers.length > 1, "This export has no usable list of layers.");
const matching = layers.filter((layer) => layer?.name === CLAUDE_LAYER_NAME);
assert(
matching.length === 1,
matching.length === 0
- ? `Aucun layer « ${CLAUDE_LAYER_NAME} » dans cet export.`
- : `Plusieurs layers « ${CLAUDE_LAYER_NAME} » : n'en garder qu'un avant l'expérience.`,
+ ? `No "${CLAUDE_LAYER_NAME}" layer in this export.`
+ : `Several "${CLAUDE_LAYER_NAME}" layers: keep only one before running this.`,
);
const index = layers.indexOf(matching[0]);
- assert(index > 0, "Le layer Claude est à l'index 0 : refus, ce layer est protégé.");
+ assert(index > 0, "The Claude layer sits at index 0: refused, that layer is protected.");
const before = JSON.stringify(layers[0]);
const changes = patchAgentRows(matching[0], { revert });
- assert(JSON.stringify(layers[0]) === before, "Le layer d'index 0 a été modifié : abandon.");
+ assert(JSON.stringify(layers[0]) === before, "The layer at index 0 was modified: aborting.");
await fs.mkdir(path.dirname(path.resolve(destination)), { recursive: true });
const output = `${JSON.stringify(raw, null, 2)}\n`;
await fs.writeFile(destination, output);
process.stdout.write(
- `Layer « ${CLAUDE_LAYER_NAME} » à l'index ${index}, lien AppSense ${matching[0].linkedAppId ?? "absent"} conservé.\n`,
+ `"${CLAUDE_LAYER_NAME}" layer at index ${index}, AppSense link ${matching[0].linkedAppId ?? "absent"} preserved.\n`,
);
- process.stdout.write(changes.length ? `${changes.join("\n")}\n` : " aucune modification nécessaire\n");
+ process.stdout.write(changes.length ? `${changes.join("\n")}\n` : " no change needed\n");
process.stdout.write(
`\n${redactHome(path.resolve(destination))}\n` +
`SHA-256 ${createHash("sha256").update(output).digest("hex")}\n\n` +
- "Importer par Input > Import Profile, puis vérifier sur le layer Claude :\n" +
+ "Import through Input > Import Profile, then check on the Claude layer:\n" +
" npm run lighting -- set all #00FF00 --effect=solid\n",
);
}
diff --git a/scripts/lib/gui-dependencies.mjs b/scripts/lib/gui-dependencies.mjs
index 99e4857..2afb324 100644
--- a/scripts/lib/gui-dependencies.mjs
+++ b/scripts/lib/gui-dependencies.mjs
@@ -2,27 +2,34 @@ import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
-const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
-
-export function ensureGuiDependencies(guiDirectory) {
+export function ensureGuiDependencies(
+ guiDirectory,
+ {
+ exists = existsSync,
+ spawn = spawnSync,
+ platform = process.platform,
+ log = console.log,
+ } = {},
+) {
+ const npmCommand = platform === "win32" ? "npm.cmd" : "npm";
const packageJson = resolve(guiDirectory, "package.json");
const viteExecutable = resolve(
guiDirectory,
"node_modules",
".bin",
- process.platform === "win32" ? "vite.cmd" : "vite",
+ platform === "win32" ? "vite.cmd" : "vite",
);
- if (!existsSync(packageJson)) {
- throw new Error("Interface introuvable : prototype/package.json est absent.");
+ if (!exists(packageJson)) {
+ throw new Error("Interface not found: prototype/package.json is missing.");
}
- if (existsSync(viteExecutable)) {
+ if (exists(viteExecutable)) {
return { installed: false };
}
- console.log("Préparation des dépendances verrouillées de l’interface…");
- const install = spawnSync(
+ log("Preparing the interface's locked dependencies…");
+ const install = spawn(
npmCommand,
["ci", "--ignore-scripts", "--no-audit", "--no-fund"],
{
@@ -32,11 +39,11 @@ export function ensureGuiDependencies(guiDirectory) {
);
if (install.error) {
- throw new Error(`Impossible de préparer l’interface : ${install.error.message}`);
+ throw new Error(`Could not prepare the interface: ${install.error.message}`);
}
if (install.status !== 0) {
- const error = new Error(`La préparation de l’interface a échoué avec le code ${install.status ?? 1}.`);
+ const error = new Error(`Preparing the interface failed with code ${install.status ?? 1}.`);
error.exitCode = install.status ?? 1;
throw error;
}
diff --git a/scripts/lib/hid-device.mjs b/scripts/lib/hid-device.mjs
index 6efa8bf..e6ce59e 100644
--- a/scripts/lib/hid-device.mjs
+++ b/scripts/lib/hid-device.mjs
@@ -1,17 +1,17 @@
-// Transport HID du Codex Micro au-dessus de node-hid (dépendance optionnelle,
-// chargée paresseusement : le reste du dépôt fonctionne sans elle).
+// Codex Micro HID transport on top of node-hid (an optional dependency, loaded
+// lazily: the rest of the repository works without it).
//
-// Réimplémentation originale d'après le format observé et documenté dans
-// docs/research/hid-lighting-protocol.md :
+// Original reimplementation from the format observed and documented in
+// docs/research/hid-lighting-protocol.md:
//
-// - interface vendeur : VID 0x303a, usage page 0xFF00 ;
-// - ouverture non exclusive sur macOS : les lectures sont diffusées à tous
-// les lecteurs, les écritures se disputent (dernière écriture gagnante) ;
-// - une requête en vol à la fois, 50 ms de pause entre appels, 10 s de
-// garde-fou par réponse ;
-// - une réponse dont l'identifiant n'est pas le nôtre est le signe qu'un
-// autre écrivain (l'app ChatGPT) vient de pousser : elle sert de
-// déclencheur de réapplication au mode --hold.
+// - vendor interface: VID 0x303a, usage page 0xFF00;
+// - non-exclusive open on macOS: reads are broadcast to every reader, writes
+// compete (last write wins);
+// - one request in flight at a time, 50ms of spacing between calls, a 10s
+// guard per response;
+// - a response whose id is not ours is the sign that another writer (the
+// ChatGPT app) has just pushed: it serves as the reapply trigger in --hold
+// mode.
import {
CHANNEL_DEBUG,
@@ -39,44 +39,45 @@ export class DeviceError extends Error {
}
}
-// Import paresseux : node-hid est une optionalDependency native. Le message
-// d'erreur doit dire quoi faire, pas seulement que ça manque.
+// Lazy import: node-hid is a native optionalDependency. The error message has to
+// say what to do, not only that it is missing.
export async function loadHid() {
try {
return await import("node-hid");
} catch {
throw new DeviceError(
"HID_UNAVAILABLE",
- "node-hid est absente : lancer `npm install` (optionalDependencies) pour activer le pilotage HID.",
+ "node-hid is missing: run `npm install` (optionalDependencies) to enable HID control.",
);
}
}
-// Énumère les interfaces vendeur du Codex Micro. Le clavier expose plusieurs
-// collections HID ; seule la page d'usage 0xFF00 transporte le canal RPC.
+// Lists the Codex Micro vendor interfaces. The keyboard exposes several HID
+// collections; only usage page 0xFF00 carries the RPC channel.
+export function isCodexVendorInterface(device) {
+ return (
+ device?.vendorId === VENDOR_ID &&
+ device?.productId === PRODUCT_ID &&
+ device?.usagePage === VENDOR_USAGE_PAGE
+ );
+}
+
export async function listInterfaces() {
const hid = await loadHid();
- return hid
- .devices()
- .filter(
- (device) =>
- device.vendorId === VENDOR_ID &&
- device.productId === PRODUCT_ID &&
- device.usagePage === VENDOR_USAGE_PAGE,
- );
+ return hid.devices().filter(isCodexVendorInterface);
}
async function openHandle(path) {
const hid = await loadHid();
- // Non exclusif sur macOS : coexister avec Input et l'app ChatGPT, qui
- // tiennent le même périphérique. Ailleurs, ouverture standard.
+ // Non-exclusive on macOS: coexist with Input and the ChatGPT app, which hold
+ // the same device. Elsewhere, a standard open.
if (process.platform === "darwin") return hid.HIDAsync.open(path, { nonExclusive: true });
return hid.HIDAsync.open(path);
}
-// Session RPC : file séquentielle cadencée, corrélation des réponses par
-// identifiant, distribution des notifications, détection des écritures
-// étrangères. Une session = une requête en vol, comme le firmware l'attend.
+// RPC session: paced sequential queue, responses correlated by id, notifications
+// dispatched, foreign writes detected. One session = one request in flight, the
+// way the firmware expects it.
export class DeviceSession {
#handle;
#assembler = createLineAssembler();
@@ -88,6 +89,7 @@ export class DeviceSession {
#queue = [];
#running = false;
#closed = false;
+ #lastCallStartedAt = 0;
constructor(handle, { onForeignWrite, onDebugLine } = {}) {
this.#handle = handle;
@@ -97,7 +99,7 @@ export class DeviceSession {
handle.on("error", (error) => this.#failAll(new DeviceError("DEVICE_ERROR", error.message)));
handle.on("close", () => {
this.#closed = true;
- this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Périphérique déconnecté."));
+ this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Device disconnected."));
});
}
@@ -106,7 +108,7 @@ export class DeviceSession {
if (!target) {
throw new DeviceError(
"DEVICE_NOT_FOUND",
- "Codex Micro introuvable sur l'interface vendeur (VID 0x303a, usage 0xFF00). Vérifier la connexion, puis `list`.",
+ "Codex Micro not found on the vendor interface (VID 0x303a, usage 0xFF00). Check the connection, then run `list`.",
);
}
return new DeviceSession(await openHandle(target), options);
@@ -114,12 +116,14 @@ export class DeviceSession {
onNotification(method, handler) {
this.#notifyHandlers.set(method, handler);
- return () => this.#notifyHandlers.delete(method);
+ return () => {
+ if (this.#notifyHandlers.get(method) === handler) this.#notifyHandlers.delete(method);
+ };
}
- // Enfile un appel et attend sa réponse. Les tâches s'exécutent une par une
- // avec CALL_SPACING_MS de pause, le firmware traitant les commandes au
- // compte-goutte.
+ // Queues a call and waits for its response. Tasks run one at a time with
+ // CALL_SPACING_MS of spacing, since the firmware handles commands in a
+ // trickle.
call(method, params = null, id = createRpcId()) {
return new Promise((resolve, reject) => {
this.#queue.push({ method, params, id, resolve, reject });
@@ -133,31 +137,44 @@ export class DeviceSession {
try {
let task;
while ((task = this.#queue.shift())) {
- await this.#run(task);
- await new Promise((resolve) => setTimeout(resolve, CALL_SPACING_MS));
+ const remainingSpacing =
+ CALL_SPACING_MS - (Date.now() - this.#lastCallStartedAt);
+ if (remainingSpacing > 0) {
+ await new Promise((resolve) => setTimeout(resolve, remainingSpacing));
+ }
+ try {
+ task.resolve(await this.#run(task));
+ } catch (error) {
+ task.reject(error);
+ }
}
} finally {
this.#running = false;
}
}
- async #run({ method, params, id, resolve, reject }) {
- if (this.#closed) return reject(new DeviceError("DEVICE_DISCONNECTED", "Session fermée."));
+ async #run({ method, params, id }) {
+ if (this.#closed) throw new DeviceError("DEVICE_DISCONNECTED", "Session closed.");
+ this.#lastCallStartedAt = Date.now();
const key = String(id);
- const timer = setTimeout(() => {
- this.#resolvers.delete(key);
- reject(new DeviceError("TIMEOUT", `Pas de réponse à ${method} en ${CALL_TIMEOUT_MS / 1000} s.`));
- }, CALL_TIMEOUT_MS);
- this.#resolvers.set(key, { resolve, reject, timer });
- try {
- for (const frame of encodeFrames(buildRequest({ method, params, id }))) {
- await this.#handle.write(frame);
- }
- } catch (error) {
- clearTimeout(timer);
- this.#resolvers.delete(key);
- reject(new DeviceError("WRITE_FAILED", error.message));
- }
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.#resolvers.delete(key);
+ reject(new DeviceError("TIMEOUT", `No answer to ${method} within ${CALL_TIMEOUT_MS / 1000}s.`));
+ }, CALL_TIMEOUT_MS);
+ this.#resolvers.set(key, { resolve, reject, timer });
+
+ const write = async () => {
+ for (const frame of encodeFrames(buildRequest({ method, params, id }))) {
+ await this.#handle.write(frame);
+ }
+ };
+ void write().catch((error) => {
+ clearTimeout(timer);
+ this.#resolvers.delete(key);
+ reject(new DeviceError("WRITE_FAILED", error.message));
+ });
+ });
}
#dispatch(data) {
@@ -165,7 +182,7 @@ export class DeviceSession {
try {
lines = this.#assembler(data);
} catch {
- return; // Rapport malformé : ignoré, le flux suivant resynchronisera.
+ return; // Malformed report: ignored; the next valid report resynchronizes the stream.
}
for (const { channel, line } of lines) {
if (channel === CHANNEL_DEBUG) {
@@ -177,7 +194,7 @@ export class DeviceSession {
if (!message) continue;
if (message.kind === "response") this.#resolve(message);
else if (message.kind === "notification") this.#notifyHandlers.get(message.method)?.(message.params);
- // Les messages sans id ni méthode sont abandonnés par l'accumulateur.
+ // Messages with neither id nor method are dropped by the accumulator.
}
}
@@ -193,10 +210,9 @@ export class DeviceSession {
}
return;
}
- // Réponse orpheline sur une méthode d'éclairage : un autre écrivain vient
- // de pousser sa configuration. C'est le seul signal fiable de
- // coexistence, et il est gratuit — les rapports d'entrée sont diffusés à
- // tous les lecteurs.
+ // Orphan response on a lighting method: another writer has just pushed its
+ // configuration. This is the only reliable coexistence signal, and it is
+ // free — input reports are broadcast to every reader.
if (message.method === METHODS.threadsLighting || message.method === METHODS.rgbConfig) {
this.#onForeignWrite?.(message.method, message.parsed);
}
@@ -213,11 +229,11 @@ export class DeviceSession {
async close() {
this.#closed = true;
- this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Session fermée."));
+ this.#failAll(new DeviceError("DEVICE_DISCONNECTED", "Session closed."));
try {
await this.#handle.close();
} catch {
- // Fermeture déjà effective côté pile HID.
+ // Already closed on the HID stack side.
}
}
}
diff --git a/scripts/lib/hid-frame.mjs b/scripts/lib/hid-frame.mjs
index aee24f9..cc44d2c 100644
--- a/scripts/lib/hid-frame.mjs
+++ b/scripts/lib/hid-frame.mjs
@@ -1,33 +1,33 @@
-// Cadrage HID du canal RPC du Codex Micro, réimplémenté d'après le format
-// observé et documenté dans docs/research/hid-lighting-protocol.md. Code
-// original, sans aucune reprise du SDK Work Louder : seuls les faits du
-// format (constantes, positions d'octets, champs JSON) sont exploités.
+// HID framing of the Codex Micro RPC channel, reimplemented from the format
+// observed and documented in docs/research/hid-lighting-protocol.md. Original
+// code, with nothing taken from the Work Louder SDK: only the facts of the
+// format (constants, byte positions, JSON fields) are used.
//
-// Le module est volontairement pur : aucun I/O, aucune dépendance au
-// périphérique. Toute la logique est testable sans matériel.
+// The module is deliberately pure: no I/O, no dependency on the device. All the
+// logic is testable without hardware.
import { randomInt } from "node:crypto";
-// Rapport de 64 octets : [0] identifiant de rapport, [1] canal, [2] longueur
-// du fragment transporté par CE rapport, [3..63] charge utile UTF-8.
+// 64-byte report: [0] report id, [1] channel, [2] length of the chunk carried by
+// THIS report, [3..63] UTF-8 payload.
export const REPORT_SIZE = 64;
export const REPORT_ID = 0x06;
export const CHANNEL_DEBUG = 1;
export const CHANNEL_RPC = 2;
-export const CHUNK_PAYLOAD = REPORT_SIZE - 3; // 61 octets par rapport
+export const CHUNK_PAYLOAD = REPORT_SIZE - 3; // 61 bytes per report
-// L'identifiant d'appel est borné par le firmware, qui refuse les valeurs hors
-// de [0, 999). Des identifiants courts évitent aussi de faire passer une
-// requête d'un rapport à deux pour quelques octets.
+// The call id is bounded by the firmware, which rejects values outside
+// [0, 999). Short ids also avoid pushing a request from one report to two for
+// the sake of a few bytes.
export const RPC_ID_LIMIT = 999;
export function createRpcId() {
return randomInt(0, RPC_ID_LIMIT);
}
-// Le canal remplace tout caractère non ASCII par son échappement \uXXXX (ou la
-// paire de substitution au-delà du BMP). Les charges d'éclairage sont en
-// pratique déjà ASCII ; l'échappement est appliqué pour coller au format.
+// The channel replaces every non-ASCII character with its \uXXXX escape (or the
+// surrogate pair beyond the BMP). Lighting payloads are already ASCII in
+// practice; the escaping is applied to match the format.
export function escapeUnicode(text) {
return text.replace(/[^\x00-\x7F]/gu, (char) => {
const codePoint = char.codePointAt(0);
@@ -40,19 +40,19 @@ export function escapeUnicode(text) {
});
}
-// Enveloppe JSON-RPC du canal : { method, params, id }. `params` vaut null
-// quand la méthode n'en attend pas.
+// The channel's JSON-RPC envelope: { method, params, id }. `params` is null when
+// the method expects none.
export function buildRequest({ method, params = null, id }) {
- if (typeof method !== "string" || !method) throw new Error("Nom de méthode RPC attendu.");
+ if (typeof method !== "string" || !method) throw new Error("Expected an RPC method name.");
if (!Number.isInteger(id) || id < 0 || id >= RPC_ID_LIMIT) {
- throw new Error(`Identifiant RPC attendu entre 0 et ${RPC_ID_LIMIT - 1}.`);
+ throw new Error(`Expected an RPC id between 0 and ${RPC_ID_LIMIT - 1}.`);
}
return escapeUnicode(JSON.stringify({ method, params, id }));
}
-// Découpe un message en rapports de 64 octets. Un message plus long que
-// CHUNK_PAYLOAD est fragmenté en rapports consécutifs qui répètent le même
-// en-tête ; seul l'octet de longueur varie. Un message vide n'émet rien.
+// Splits a message into 64-byte reports. A message longer than CHUNK_PAYLOAD is
+// fragmented into consecutive reports that repeat the same header; only the
+// length byte varies. An empty message emits nothing.
export function encodeFrames(message) {
const buffer = Buffer.from(message, "utf8");
const frames = [];
@@ -70,11 +70,11 @@ export function encodeFrames(message) {
return frames;
}
-// Extrait canal et charge utile d'un rapport entrant. Le tampon livré par la
-// pile HID inclut l'identifiant de rapport en octet 0, comme à l'émission.
+// Extracts channel and payload from an incoming report. The buffer delivered by
+// the HID stack includes the report id in byte 0, as it does on the way out.
export function decodeReport(report) {
const data = Buffer.isBuffer(report) ? report : Buffer.from(report);
- if (data.length < 3) throw new Error(`Rapport HID trop court : ${data.length} octet(s).`);
+ if (data.length < 3) throw new Error(`HID report too short: ${data.length} byte(s).`);
const channel = data[1];
const length = data[2];
return {
@@ -84,10 +84,10 @@ export function decodeReport(report) {
};
}
-// Réassemble le flux de rapports en lignes. Le périphérique termine chaque
-// message par un saut de ligne ; un message long arrive fragmenté sur
-// plusieurs rapports et n'est complet qu'au saut de ligne final. Chaque canal
-// a son propre tampon : les journaux de débogage ne se mélangent pas au RPC.
+// Reassembles the stream of reports into lines. The device terminates each
+// message with a newline; a long message arrives fragmented over several
+// reports and is only complete at the final newline. Each channel has its own
+// buffer: debug logs do not mix into the RPC.
export function createLineAssembler() {
const buffers = new Map();
return function push(report) {
@@ -99,13 +99,13 @@ export function createLineAssembler() {
};
}
-// Reconstitue les messages JSON-RPC à partir des lignes du canal RPC. Un
-// document peut lui-même arriver en plusieurs lignes (JSON indenté) : on
-// accumule jusqu'à ce que l'analyse réussisse. Trois formes sur le canal :
+// Rebuilds JSON-RPC messages from the lines of the RPC channel. A document can
+// itself arrive over several lines (indented JSON): accumulate until parsing
+// succeeds. Three shapes on the channel:
//
-// - réponse : { "result": …, "id": n } (id aussi sous « i »)
-// - notification : { "method": "v.oai.hid", "params": … } (aussi « m »/« p »)
-// - invalide : ni id ni méthode — tampon abandonné
+// - response: { "result": …, "id": n } (id also carried as "i")
+// - notification: { "method": "v.oai.hid", "params": … } (also "m"/"p")
+// - invalid: neither id nor method — buffer dropped
export function createRpcAccumulator() {
let pending = "";
return function push(text) {
diff --git a/scripts/lib/hid-lighting.mjs b/scripts/lib/hid-lighting.mjs
index f7212ba..7f7d6ee 100644
--- a/scripts/lib/hid-lighting.mjs
+++ b/scripts/lib/hid-lighting.mjs
@@ -1,16 +1,15 @@
-// Modèle d'éclairage du Codex Micro, réimplémenté d'après le format observé
-// et documenté dans docs/research/hid-lighting-protocol.md. Code original.
+// Codex Micro lighting model, reimplemented from the format observed and
+// documented in docs/research/hid-lighting-protocol.md. Original code.
//
-// Deux méthodes JSON-RPC vendeur couvrent l'éclairage :
+// Two vendor JSON-RPC methods cover the lighting:
//
-// v.oai.thstatus éclairage par emplacement (« thread ») : les six touches
-// Agent, adressées par `id`, mises à jour partielles
-// possibles — un champ omis reste inchangé sur le
-// périphérique ;
-// v.oai.rgbcfg deux zones globales : `ambient` (anneau extérieur) et
-// `keys` (sous les keycaps).
+// v.oai.thstatus per-slot ("thread") lighting: the six Agent keys, addressed
+// by `id`, with partial updates possible — an omitted field
+// stays unchanged on the device;
+// v.oai.rgbcfg two global zones: `ambient` (outer ring) and `keys`
+// (under the keycaps).
//
-// Le module est pur : il ne produit que des objets de paramètres, sans I/O.
+// The module is pure: it only produces parameter objects, with no I/O.
import { SLOT_CONTROLS, STATE_COLORS, STATES } from "./thread-slots.mjs";
@@ -21,8 +20,8 @@ export const METHODS = Object.freeze({
notifyJoystick: "v.oai.rad",
});
-// Effets d'animation du firmware. `solid` est le seul utile pour un témoin
-// d'état stable ; les autres sont exposés pour les usages libres.
+// Firmware animation effects. `solid` is the only useful one for a steady state
+// light; the others are exposed for free-form use.
export const EFFECTS = Object.freeze({
off: 0,
solid: 1,
@@ -33,40 +32,40 @@ export const EFFECTS = Object.freeze({
shallowBreath: 6,
});
-// Correspondance emplacement → identifiant de thread sur le canal. CONFIRMÉE sur
-// matériel par `node scripts/lighting.mjs probe --delay=3000`, qui allume les
-// touches une par une : la séquence des identifiants 0 à 5 suit exactement
-// l'ordre physique de SLOT_CONTROLS — les deux touches de la rangée du haut, de
-// gauche à droite, puis les quatre de la rangée suivante.
+// Slot → thread id mapping on the channel. CONFIRMED on hardware by
+// `node scripts/lighting.mjs probe --delay=3000`, which lights the keys one by
+// one: the sequence of ids 0 to 5 follows exactly the physical order of
+// SLOT_CONTROLS — the two keys of the top row, left to right, then the four of
+// the next row.
export const SLOT_THREAD_IDS = Object.freeze(SLOT_CONTROLS.map((_, index) => index));
-// « #D97757 » → 0xD97757. Le canal attend un entier RGB compacté.
+// "#D97757" → 0xD97757. The channel expects a packed RGB integer.
export function colorToInt(color) {
if (typeof color === "number" && Number.isInteger(color) && color >= 0 && color <= 0xffffff) {
return color;
}
const match = typeof color === "string" && color.match(/^#?([0-9a-fA-F]{6})$/);
- if (!match) throw new Error(`Couleur attendue au format #RRGGBB : ${color}`);
+ if (!match) throw new Error(`Expected a colour in #RRGGBB format: ${color}`);
return Number.parseInt(match[1], 16);
}
function clampUnit(name, value) {
if (typeof value !== "number" || Number.isNaN(value) || value < 0 || value > 1) {
- throw new Error(`${name} attendu entre 0 et 1 : ${value}`);
+ throw new Error(`${name} expected between 0 and 1: ${value}`);
}
return value;
}
-// Une entrée d'éclairage par emplacement. Seul `id` est obligatoire ; chaque
-// champ optionnel omis laisse le paramètre correspondant inchangé. Les clés
-// minimisées (`c`, `b`, `e`, `s`, `sk`, `sa`) sont le format du canal.
+// One per-slot lighting entry. Only `id` is required; every optional field
+// omitted leaves the corresponding parameter unchanged. The minified keys
+// (`c`, `b`, `e`, `s`, `sk`, `sa`) are the channel's own format.
export function threadEntry({ id, color, brightness, effect, speed, syncKeysLighting, syncAmbientLighting }) {
- if (!Number.isInteger(id) || id < 0) throw new Error(`Identifiant de thread attendu entier ≥ 0 : ${id}`);
+ if (!Number.isInteger(id) || id < 0) throw new Error(`Expected an integer thread id ≥ 0: ${id}`);
const entry = { id };
if (color !== undefined && color !== null) entry.c = colorToInt(color);
if (brightness !== undefined) entry.b = clampUnit("brightness", brightness);
if (effect !== undefined) {
- if (!Object.values(EFFECTS).includes(effect)) throw new Error(`Effet inconnu : ${effect}`);
+ if (!Object.values(EFFECTS).includes(effect)) throw new Error(`Unknown effect: ${effect}`);
entry.e = effect;
}
if (speed !== undefined) entry.s = clampUnit("speed", speed);
@@ -75,13 +74,13 @@ export function threadEntry({ id, color, brightness, effect, speed, syncKeysLigh
return entry;
}
-// Paramètres de v.oai.thstatus : un tableau d'entrées, une par emplacement.
+// v.oai.thstatus parameters: an array of entries, one per slot.
export function threadsLightingParams(entries) {
return entries.map(threadEntry);
}
-// Une zone de v.oai.rgbcfg. Les cinq champs sont obligatoires : la méthode
-// décrit une configuration complète de zone, pas une mise à jour partielle.
+// One v.oai.rgbcfg zone. All five fields are required: the method describes a
+// complete zone configuration, not a partial update.
export function zoneSide({ effect, brightness, speed, magic, color }) {
return {
e: effect,
@@ -96,12 +95,12 @@ export function rgbConfigParams({ ambient, keys }) {
return { ambient: zoneSide(ambient), keys: zoneSide(keys) };
}
-// Traduction des six emplacements de thread-status en entrées thstatus.
-// Un emplacement libre est éteint (brightness 0) ; les autres portent la
-// couleur d'état de la palette du dépôt en effet fixe.
+// Translates the six thread-status slots into thstatus entries. A free slot is
+// unlit (brightness 0); the others carry their state colour from the repository
+// palette, as a solid effect.
export function slotsToThreadEntries(rows, { brightness = 1 } = {}) {
if (!Array.isArray(rows) || rows.length !== SLOT_CONTROLS.length) {
- throw new Error(`${SLOT_CONTROLS.length} emplacements attendus.`);
+ throw new Error(`Expected ${SLOT_CONTROLS.length} slots.`);
}
return rows.map((row, index) => {
const state = row?.state ?? STATES.free;
@@ -111,7 +110,7 @@ export function slotsToThreadEntries(rows, { brightness = 1 } = {}) {
});
}
-// Éteint les six emplacements sans toucher aux autres paramètres.
+// Turns the six slots off without touching the other parameters.
export function allOffParams() {
return SLOT_THREAD_IDS.map((id) => threadEntry({ id, brightness: 0 }));
}
diff --git a/scripts/lib/thread-slots.mjs b/scripts/lib/thread-slots.mjs
index 80769fb..f2623cd 100644
--- a/scripts/lib/thread-slots.mjs
+++ b/scripts/lib/thread-slots.mjs
@@ -1,46 +1,45 @@
-// Réduction des sessions Claude Code vivantes à six emplacements physiques, un
-// par touche Agent du Codex Micro.
+// Reduces the live Claude Code sessions to six physical slots, one per Agent key
+// on the Codex Micro.
//
-// Le module est volontairement pur : il ne lance aucun processus, n'écrit aucun
-// fichier et ne lit aucun transcript. Toute la logique d'attribution et de
-// transition est donc testable sans Claude en cours d'exécution.
+// The module is deliberately pure: it spawns no process, writes no file and
+// reads no transcript. All the assignment and transition logic is therefore
+// testable without a running Claude.
//
-// Deux sources alimentent le réducteur, et elles ne sont pas interchangeables :
+// Two sources feed the reducer, and they are not interchangeable:
//
-// - le roster (`claude agents --json`) est l'autorité sur l'appartenance :
-// lui seul décide quelle session occupe un emplacement ;
-// - les hooks sont l'autorité sur l'état : eux seuls savent si un tour est en
-// cours, terminé ou bloqué sur une décision.
+// - the roster (`claude agents --json`) is the authority on membership: it
+// alone decides which session occupies a slot;
+// - the hooks are the authority on state: they alone know whether a turn is
+// running, finished, or blocked on a decision.
//
-// Cette séparation n'est pas esthétique. Un hook manqué (crash, `kill -9`) fige
-// l'état à `running` pour toujours ; le roster le rattrape. Inversement le
-// roster ne publie aucun état ; les hooks le fournissent instantanément.
+// This separation is not cosmetic. A missed hook (crash, `kill -9`) would pin
+// the state to `running` forever; the roster catches that. Conversely the roster
+// publishes no state at all; the hooks deliver it instantly.
export const SLOT_COUNT = 6;
-// Les six touches Agent, dans l'ordre de lecture physique : rangée du haut
-// (deux touches) puis rangée suivante (quatre touches). Les identifiants
-// proviennent de KEY_CONTROL_LOCATIONS dans shared/input-profile.mjs.
+// The six Agent keys, in physical reading order: top row (two keys) then the
+// next row (four keys). The ids come from KEY_CONTROL_LOCATIONS in
+// shared/input-profile.mjs.
export const SLOT_CONTROLS = Object.freeze(["key-9", "key-10", "key-5", "key-6", "key-7", "key-8"]);
-// Réexportées depuis shared/, où elles sont la source unique de vérité : le GUI
-// affiche la même légende, et une palette dupliquée finirait par diverger.
+// Re-exported from shared/, where they are the single source of truth: the GUI
+// shows the same legend, and a duplicated palette would end up diverging.
export { LEGEND_ORDER, STATE_COLORS, STATES } from "../../shared/thread-status-palette.mjs";
import { STATE_COLORS, STATES } from "../../shared/thread-status-palette.mjs";
-// Les types de notification qui exigent une action humaine. Tout autre type
-// (`auth_success`, `elicitation_complete`…) ne change pas l'état : un témoin
-// rouge doit signifier « on t'attend », rien d'autre.
+// The notification types that require a human. Any other type
+// (`auth_success`, `elicitation_complete`…) leaves the state alone: a red light
+// has to mean "you are being waited for", and nothing else.
const BLOCKING_NOTIFICATIONS = new Set([
"permission_prompt",
"agent_needs_input",
"elicitation_dialog",
]);
-// Les événements de hook peuvent précéder l'apparition de la session dans le
-// roster, et certaines sessions n'y apparaissent jamais (`claude -p`, sous-agents).
-// On garde leur dernier état en attente, borné, plutôt que de leur ouvrir un
-// emplacement.
+// Hook events can arrive before the session shows up in the roster, and some
+// sessions never show up at all (`claude -p`, subagents). Their last state is
+// held in a bounded pending map rather than given a slot.
const MAX_PENDING = 32;
export function stateFromHookEvent(event) {
@@ -72,7 +71,7 @@ export function emptySnapshot() {
};
}
-// Une copie défensive suffit : les entrées sont des objets plats.
+// A defensive shallow copy is enough: the entries are flat objects.
function cloneSnapshot(snapshot) {
return {
version: 1,
@@ -102,9 +101,9 @@ function slotOf(snapshot, sessionId) {
return snapshot.slots.findIndex((entry) => entry?.sessionId === sessionId);
}
-// Ordre d'éviction : un emplacement libre, puis la session terminée la plus
-// ancienne, puis la session au repos la plus ancienne. Une session vivante qui
-// travaille ou qui attend une décision n'est jamais évincée.
+// Eviction order: a free slot, then the oldest closed session, then the oldest
+// finished one. A live session that is working, or waiting on a decision, is
+// never evicted.
const EVICTABLE = [STATES.ended, STATES.done];
function claimSlot(snapshot) {
@@ -135,9 +134,8 @@ function rememberPending(snapshot, sessionId, state) {
}
/**
- * Applique un événement de hook. Une session absente du roster ne reçoit pas
- * d'emplacement : son état est mis en attente et sera promu si le roster la
- * confirme.
+ * Applies a hook event. A session missing from the roster gets no slot: its
+ * state is held pending, and gets promoted if the roster later confirms it.
*/
export function applyHookEvent(snapshot, event, now = 0) {
const next = cloneSnapshot(snapshot);
@@ -152,9 +150,9 @@ export function applyHookEvent(snapshot, event, now = 0) {
}
const entry = next.slots[index];
- // Un `Stop` qui suit un `blocked` est légitime : la décision a été prise et le
- // tour s'est terminé. Aucune transition n'est donc interdite ici, on garde
- // seulement la trace du moment du changement.
+ // A `Stop` following a `blocked` is legitimate: the decision was made and the
+ // turn ended. No transition is forbidden here, then — only the moment of the
+ // change is recorded.
const changed = entry.state !== state;
entry.state = state;
entry.updatedAt = now;
@@ -165,11 +163,11 @@ export function applyHookEvent(snapshot, event, now = 0) {
}
/**
- * Réconcilie le roster officiel. Crée les entrées manquantes, rafraîchit les
- * métadonnées, et marque `ended` toute session dont le processus a disparu.
+ * Reconciles the official roster. Creates the missing entries, refreshes the
+ * metadata, and marks `ended` any session whose process has disappeared.
*
- * @param roster tableau de `claude agents --json`, éventuellement enrichi d'un
- * champ `tty` et `terminalApp` par l'appelant.
+ * @param roster array from `claude agents --json`, optionally enriched with
+ * `tty` and `terminalApp` fields by the caller.
*/
export function applyRoster(snapshot, roster, now = 0) {
const next = cloneSnapshot(snapshot);
@@ -184,7 +182,7 @@ export function applyRoster(snapshot, roster, now = 0) {
index = claimSlot(next);
if (index === -1) {
next.overflow += 1;
- notes.push(`Aucun emplacement libre pour la session ${row.sessionId}.`);
+ notes.push(`No free slot for session ${row.sessionId}.`);
continue;
}
const promoted = next.pending[row.sessionId];
@@ -204,8 +202,8 @@ export function applyRoster(snapshot, roster, now = 0) {
changed = true;
}
}
- // Le processus est revenu dans le roster alors qu'on l'avait déclaré mort :
- // le cas n'est pas censé arriver, mais le roster reste l'autorité.
+ // The process came back into the roster after we had declared it dead: this
+ // is not supposed to happen, but the roster remains the authority.
if (entry.state === STATES.ended) {
entry.state = STATES.idle;
entry.updatedAt = now;
@@ -234,14 +232,14 @@ export function slotView(snapshot) {
}
/**
- * Normalise le terminal rapporté par `ps -o tty=` en chemin de périphérique.
+ * Normalises the terminal reported by `ps -o tty=` into a device path.
*
- * macOS renvoie déjà la forme préfixée (`ttys001`), là où d'autres BSD renvoient
- * la forme courte (`s001`). Préfixer `/dev/tty` sans distinguer les deux
- * fabriquait `/dev/ttyttys001` : un chemin inexistant, que l'AppleScript de
- * focus comparait au `tty` réel de chaque fenêtre sans jamais pouvoir
- * correspondre. Les seules sessions pourtant navigables échouaient donc toutes
- * en « fenêtre introuvable ».
+ * macOS already returns the prefixed form (`ttys001`), where other BSDs return
+ * the short form (`s001`). Prefixing `/dev/tty` without telling the two apart
+ * produced `/dev/ttyttys001`: a path that does not exist, which the focus
+ * AppleScript compared against each window's real `tty` and could never match.
+ * The only navigable sessions were therefore all failing with "window not
+ * found".
*/
export function ttyDevice(tty) {
if (!tty || tty === "??" || tty === "-") return null;
@@ -250,20 +248,20 @@ export function ttyDevice(tty) {
}
/**
- * Le `session` de `claude://resume` est validé par une regex UUID stricte avant
- * d'être repris. Un identifiant d'une autre forme est refusé par l'application :
- * on ne fabrique donc l'URL que pour ce que le handler acceptera.
+ * The `session` of `claude://resume` is validated against a strict UUID regex
+ * before being resumed. An id of any other shape is rejected by the
+ * application, so the URL is only built for what the handler will accept.
*/
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
- * Traduit un emplacement en action de navigation. Ne décide rien d'irréversible
- * et n'invente aucune route : une session que l'on ne sait pas atteindre renvoie
- * `unsupported` plutôt qu'une URL supposée.
+ * Turns a slot into a navigation action. Decides nothing irreversible and
+ * invents no route: a session we do not know how to reach returns `unsupported`
+ * rather than a guessed URL.
*
- * Une session sans `tty` est hébergée par Claude Desktop. `claude://resume` la
- * désigne par son `sessionId` — celui du roster, pas le `hostSessionId`, qui
- * regroupe plusieurs sessions et n'en adresse aucune.
+ * A session with no `tty` is hosted by Claude Desktop. `claude://resume`
+ * addresses it by its `sessionId` — the roster's, not the `hostSessionId`,
+ * which groups several sessions and addresses none of them.
*/
export function resolveNavigation(entry) {
if (!entry) return { kind: "empty" };
@@ -287,6 +285,6 @@ export function resolveNavigation(entry) {
sessionId: entry.sessionId,
entrypoint: entry.entrypoint ?? null,
hostSessionId: entry.hostSessionId ?? null,
- reason: "Session sans terminal et sans identifiant UUID : `claude://resume` refuserait cette cible.",
+ reason: "Session with no terminal and no UUID id: `claude://resume` would reject this target.",
};
}
diff --git a/scripts/lighting-probe.mjs b/scripts/lighting-probe.mjs
index bc483f0..45f4f62 100644
--- a/scripts/lighting-probe.mjs
+++ b/scripts/lighting-probe.mjs
@@ -1,48 +1,48 @@
#!/usr/bin/env node
-// SPIKE — sonde de cadrage HID, côté Node.
+// SPIKE — HID framing probe, Node side.
//
-// PÉRIMÉ POUR L'ÉCRITURE. Mesuré depuis : `node-hid` ne peut pas ouvrir ce
-// périphérique sur macOS. Il embarque hidapi 0.15.0, qui ouvre en mode *seize*
-// et n'expose pas `hid_darwin_set_open_exclusive` ; macOS refuse la saisie même
-// avec « Surveillance des saisies » accordée. `--send` échouera donc toujours.
-// Le cadre a été confirmé autrement, par scripts/lib/hid-probe.swift, en
-// ouverture non exclusive via IOKit.
+// OBSOLETE FOR WRITING. Measured since: `node-hid` cannot open this device on
+// macOS. It ships hidapi 0.15.0, which opens in *seize* mode and does not expose
+// `hid_darwin_set_open_exclusive`; macOS refuses the grab even with "Input
+// Monitoring" granted. `--send` will therefore always fail. The frame was
+// confirmed another way, by scripts/lib/hid-probe.swift, opening
+// non-exclusively through IOKit.
//
-// Ce fichier reste utile pour une seule chose : l'énumération en lecture seule,
-// qui montre les quatre collections là où IOKit n'expose qu'un périphérique.
+// This file stays useful for one thing only: the read-only enumeration, which
+// shows the four collections where IOKit exposes a single device.
//
-// Ce fichier existe pour lever une seule incertitude, pas pour devenir le
-// DeviceAdapter : il est destiné à être jeté une fois le vrai transport écrit.
+// It exists to settle one uncertainty, not to become the DeviceAdapter: it is
+// meant to be thrown away once the real transport is written.
//
-// L'incertitude : le cadre rapporté — rapports de 64 octets, octet 0 = report ID
-// `0x06`, octet 1 = canal `2`, octet 2 = longueur, charge utile UTF-8 ensuite —
-// n'a jamais été vérifié à l'exécution. Tant qu'il ne l'est pas, écrire les
-// bibliothèques d'encodage et leurs tests reviendrait à fabriquer de la
-// confiance : les tests passeraient au vert en encodant du néant.
+// The uncertainty: the reported frame — 64-byte reports, byte 0 = report ID
+// `0x06`, byte 1 = channel `2`, byte 2 = length, UTF-8 payload after that — had
+// never been verified at runtime. Until it is, writing the encoding libraries
+// and their tests would be manufacturing confidence: the tests would go green
+// while encoding nothing.
//
-// Deux précautions dictent la forme de la sonde :
+// Two precautions dictate the shape of the probe:
//
-// 1. Elle ne fait rien par défaut. Sans `--send`, elle énumère et affiche la
-// trame qu'elle enverrait. Écrire sur un périphérique est un effet de bord,
-// il demande un geste explicite.
-// 2. Sa charge utile est un no-op. Le SDK observé documente « Only the thread
-// id is required on each entry » et « omit optional fields to leave those
-// parameters unchanged on the device » : une entrée réduite à `{"id":0}`
-// prouve donc le cadre sans changer une seule couleur.
+// 1. It does nothing by default. Without `--send`, it enumerates and prints
+// the frame it would send. Writing to a device is a side effect; it takes
+// an explicit gesture.
+// 2. Its payload is a no-op. The observed SDK documents "Only the thread id is
+// required on each entry" and "omit optional fields to leave those
+// parameters unchanged on the device": an entry reduced to `{"id":0}`
+// therefore proves the frame without changing a single colour.
//
-// Elle n'écrit aucun fichier, ne touche ni au stockage Input, ni au firmware,
-// ni à l'app ChatGPT. Voir docs/research/thread-status-feasibility.md.
+// It writes no file, and touches neither Input's storage, nor the firmware, nor
+// the ChatGPT app. See docs/research/thread-status-feasibility.md.
const VENDOR_ID = 0x303a;
const PRODUCT_ID = 0x8360;
-// Le cadre à éprouver, surchargeable pour l'itération bornée de l'étape 2.
+// The frame under test, overridable for the bounded iteration of step 2.
const DEFAULT_FRAME = { reportId: 0x06, channel: 0x02, size: 64, headerLength: 3 };
-// Variantes plausibles si le cadre par défaut échoue. Bornées volontairement :
-// une sonde qui balaie l'espace complet des octets écrit n'importe quoi sur un
-// périphérique qui est aussi un clavier.
+// Plausible variants should the default frame fail. Deliberately bounded: a
+// probe sweeping the full byte space writes anything at all to a device that is
+// also a keyboard.
const FRAME_VARIANTS = [
{ reportId: 0x06, channel: 0x02, size: 64, headerLength: 3 },
{ reportId: 0x06, channel: 0x02, size: 65, headerLength: 3 },
@@ -90,11 +90,11 @@ function encode(payload, frame) {
const bytes = Buffer.from(payload, "utf8");
const capacity = frame.size - frame.headerLength;
if (bytes.length > capacity) {
- // La continuation multi-rapports est hors périmètre de la sonde : toutes ses
- // charges utiles tiennent dans un rapport, par construction.
+ // Multi-report continuation is out of scope for the probe: all of its
+ // payloads fit in one report, by construction.
throw new Error(
- `Charge utile de ${bytes.length} octets pour une capacité de ${capacity}. ` +
- "La sonde n'implémente pas la continuation.",
+ `Payload of ${bytes.length} bytes for a capacity of ${capacity}. ` +
+ "The probe does not implement continuation.",
);
}
const report = Buffer.alloc(frame.size, 0);
@@ -105,13 +105,13 @@ function encode(payload, frame) {
return report;
}
-// La lecture est volontairement tolérante : savoir *quelle* interprétation
-// fonctionne est précisément ce que la sonde doit rapporter.
+// Reading is deliberately tolerant: knowing *which* interpretation works is
+// precisely what the probe has to report.
function decode(buffer) {
const bytes = Buffer.from(buffer);
const attempts = [
- { label: "en-tête à 3 octets, longueur en [2]", start: 3, length: bytes[2] },
- { label: "report ID retiré par hidapi, longueur en [1]", start: 2, length: bytes[1] },
+ { label: "3-byte header, length in [2]", start: 3, length: bytes[2] },
+ { label: "report ID stripped by hidapi, length in [1]", start: 2, length: bytes[1] },
];
for (const attempt of attempts) {
if (!Number.isInteger(attempt.length) || attempt.length <= 0) continue;
@@ -119,7 +119,7 @@ function decode(buffer) {
try {
return { json: JSON.parse(slice), interpretation: attempt.label, raw: slice };
} catch {
- // Interprétation suivante.
+ // Next interpretation.
}
}
const text = bytes.toString("utf8");
@@ -129,7 +129,7 @@ function decode(buffer) {
try {
return {
json: JSON.parse(text.slice(first, last + 1)),
- interpretation: "JSON retrouvé par balayage, cadre non conforme",
+ interpretation: "JSON recovered by scanning, frame does not conform",
raw: text.slice(first, last + 1),
};
} catch {
@@ -146,9 +146,9 @@ async function loadHid() {
return (await import("node-hid")).default ?? (await import("node-hid"));
} catch (error) {
throw new Error(
- "node-hid est absent. Il est en optionalDependencies :\n" +
+ "node-hid is missing. It sits in optionalDependencies:\n" +
" npm install node-hid\n" +
- `Cause : ${error.message}`,
+ `Cause: ${error.message}`,
);
}
}
@@ -162,9 +162,9 @@ function describe(entry) {
].join(" ");
}
-// La collection vendor est préférée si elle existe : sur macOS, l'accès à une
-// collection d'usage clavier est soumis à l'autorisation « Surveillance des
-// saisies », pas celui d'une collection vendor.
+// The vendor collection is preferred when it exists: on macOS, access to a
+// keyboard-usage collection is gated by the "Input Monitoring" permission, where
+// access to a vendor collection is not.
function chooseInterface(entries) {
const vendor = entries.find((entry) => (entry.usagePage ?? 0) >= 0xff00);
return { entry: vendor ?? entries[0], isVendor: Boolean(vendor) };
@@ -177,18 +177,18 @@ function openDevice(HID, entry) {
const message = String(error?.message ?? error);
if (/permission|not permitted|cannot open|privile/i.test(message)) {
throw new Error(
- "Ouverture refusée par macOS.\n" +
- " Mesuré : le refus vise aussi la collection vendor. Le verrou porte donc\n" +
- " sur le périphérique — qui expose un usage clavier — et non sur la\n" +
- " collection visée. L'autorisation « Surveillance des saisies » est requise.\n" +
+ "macOS refused to open the device.\n" +
+ " Measured: the refusal covers the vendor collection too. The lock is on\n" +
+ " the device — which exposes a keyboard usage — not on the targeted\n" +
+ " collection. The \"Input Monitoring\" permission is required.\n" +
"\n" +
- " Attention au processus responsable : macOS attribue l'autorisation à\n" +
- " l'application parente, pas à l'exécutable `node`. Lancée depuis un agent\n" +
- " ou un IDE, la sonde demande l'autorisation pour cette application-là.\n" +
- " Lancer depuis Terminal.app, et autoriser Terminal :\n" +
- " Réglages Système > Confidentialité et sécurité > Surveillance des saisies\n" +
+ " Mind the responsible process: macOS grants the permission to the parent\n" +
+ " application, not to the `node` executable. Started from an agent or an\n" +
+ " IDE, the probe asks for the permission on behalf of that application.\n" +
+ " Run it from Terminal.app, and authorise Terminal:\n" +
+ " System Settings > Privacy & Security > Input Monitoring\n" +
"\n" +
- ` Message d'origine : ${message}`,
+ ` Original message: ${message}`,
);
}
throw error;
@@ -209,7 +209,7 @@ function exchange(device, payload, frame) {
const decoded = decode(data);
if (decoded.json) return { ok: true, ...decoded };
}
- return { ok: false, error: "aucune réponse exploitable" };
+ return { ok: false, error: "no usable answer" };
}
// --- sonde -------------------------------------------------------------------
@@ -223,12 +223,12 @@ const NO_OP_REQUEST = JSON.stringify({
function reportFrame(frame, payload) {
const report = encode(payload, frame);
process.stdout.write(
- ` cadre report ID ${hex(frame.reportId)}, canal ${hex(frame.channel)}, ` +
- `${frame.size} octets, en-tête ${frame.headerLength}\n` +
- ` charge ${payload}\n` +
- ` ${Buffer.byteLength(payload, "utf8")} octets sur ` +
- `${frame.size - frame.headerLength} disponibles\n` +
- ` trame ${report.subarray(0, 16).toString("hex")}…\n`,
+ ` frame report ID ${hex(frame.reportId)}, channel ${hex(frame.channel)}, ` +
+ `${frame.size} bytes, header ${frame.headerLength}\n` +
+ ` payload ${payload}\n` +
+ ` ${Buffer.byteLength(payload, "utf8")} bytes of ` +
+ `${frame.size - frame.headerLength} available\n` +
+ ` report ${report.subarray(0, 16).toString("hex")}…\n`,
);
}
@@ -238,24 +238,24 @@ async function probe(options) {
(entry) => entry.vendorId === VENDOR_ID && entry.productId === PRODUCT_ID,
);
- process.stdout.write(`Périphérique ${hex(VENDOR_ID, 4)}:${hex(PRODUCT_ID, 4)}\n`);
+ process.stdout.write(`Device ${hex(VENDOR_ID, 4)}:${hex(PRODUCT_ID, 4)}\n`);
if (entries.length === 0) {
- return fail(" Introuvable. Le Codex Micro est-il connecté, en USB ou en Bluetooth ?");
+ return fail(" Not found. Is the Codex Micro connected, over USB or Bluetooth?");
}
for (const entry of entries) process.stdout.write(`${describe(entry)}\n`);
const { entry, isVendor } = chooseInterface(entries);
process.stdout.write(
- ` choisie interface ${entry.interface ?? "?"}, ` +
- `${isVendor ? "collection vendor" : "collection clavier — autorisation macOS probable"}\n\n`,
+ ` chosen interface ${entry.interface ?? "?"}, ` +
+ `${isVendor ? "vendor collection" : "keyboard collection — macOS permission likely needed"}\n\n`,
);
reportFrame(options.frame, NO_OP_REQUEST);
if (!options.send) {
process.stdout.write(
- "\n Rien n'a été écrit. Relancer avec --send pour éprouver le cadre.\n" +
- " La charge est un no-op : elle ne change aucune couleur.\n",
+ "\n Nothing was written. Run again with --send to exercise the frame.\n" +
+ " The payload is a no-op: it changes no colour.\n",
);
return undefined;
}
@@ -264,13 +264,13 @@ async function probe(options) {
try {
const candidates = options.variants ? FRAME_VARIANTS : [options.frame];
for (const [index, frame] of candidates.entries()) {
- if (index > 0) process.stdout.write(`\n variante ${index} :\n`), reportFrame(frame, NO_OP_REQUEST);
+ if (index > 0) process.stdout.write(`\n variant ${index}:\n`), reportFrame(frame, NO_OP_REQUEST);
const result = exchange(device, NO_OP_REQUEST, frame);
if (result.ok) {
process.stdout.write(
- `\n ✔ CADRE CONFIRMÉ\n` +
- ` interprétation : ${result.interpretation}\n` +
- ` réponse : ${result.raw}\n`,
+ `\n ✔ FRAME CONFIRMED\n` +
+ ` interpretation: ${result.interpretation}\n` +
+ ` answer: ${result.raw}\n`,
);
if (options.map) await mapSlots(device, frame);
return undefined;
@@ -278,20 +278,20 @@ async function probe(options) {
process.stdout.write(` ✘ ${result.error}\n`);
}
return fail(
- "\n Aucun cadre n'a répondu. Le format d'en-tête est à revoir :\n" +
- " relire localement le bundle ChatGPT.app pour fixer l'en-tête et la\n" +
- " continuation, puis élargir FRAME_VARIANTS de façon bornée.",
+ "\n No frame answered. The header format needs revisiting:\n" +
+ " re-read the ChatGPT.app bundle locally to pin down the header and the\n" +
+ " continuation, then widen FRAME_VARIANTS in a bounded way.",
);
} finally {
device.close();
}
}
-// Modifie l'éclairage : réservé à `--map`, jamais fait implicitement.
+// Changes the lighting: reserved for `--map`, never done implicitly.
async function mapSlots(device, frame) {
process.stdout.write(
- "\n Balayage des six emplacements. L'éclairage est modifié ;\n" +
- " l'app ChatGPT le rétablira à sa prochaine poussée, sous 35 à 40 s.\n",
+ "\n Sweeping the six slots. The lighting is modified;\n" +
+ " the ChatGPT app will restore it on its next push, within 35 to 40s.\n",
);
const restore = () => {
try {
@@ -302,7 +302,7 @@ async function mapSlots(device, frame) {
});
device.write([...encode(off, frame)]);
} catch {
- // Le périphérique est peut-être déjà fermé : rien de mieux à tenter.
+ // The device may already be closed: nothing better to try.
}
};
process.on("SIGINT", () => (restore(), process.exit(130)));
@@ -315,19 +315,19 @@ async function mapSlots(device, frame) {
});
const result = exchange(device, request, frame);
process.stdout.write(
- ` id ${id} → ${result.ok ? "accusé reçu" : `échec : ${result.error}`}` +
- " — quelle touche s'est allumée ?\n",
+ ` id ${id} → ${result.ok ? "acknowledged" : `failed: ${result.error}`}` +
+ " — which key lit up?\n",
);
await new Promise((resolve) => setTimeout(resolve, 1200));
}
restore();
process.stdout.write(
- "\n Reporter la correspondance id → touche dans SLOT_THREAD_IDS,\n" +
- " puis figer les lignes « rapporté, non revérifié » de la note de recherche.\n",
+ "\n Record the id → key mapping in SLOT_THREAD_IDS,\n" +
+ " then freeze the \"reported, not re-verified\" rows of the research note.\n",
);
}
-// --- entrée ------------------------------------------------------------------
+// --- entry point --------------------------------------------------------------
function parseArguments(argv) {
const numeric = (flag, fallback) => {
diff --git a/scripts/lighting.mjs b/scripts/lighting.mjs
index 0411b91..859e19b 100644
--- a/scripts/lighting.mjs
+++ b/scripts/lighting.mjs
@@ -1,17 +1,17 @@
#!/usr/bin/env node
-// Pilotage local de l'éclairage du Codex Micro : couleur et effet des six
-// touches Agent, zones globales, écoute des événements touches et joystick.
+// Local control of the Codex Micro lighting: colour and effect of the six Agent
+// keys, global zones, and listening to key and joystick events.
//
-// Réimplémentation originale du format observé, documentée dans
-// docs/research/hid-lighting-protocol.md. Aucune écriture persistante : seuls
-// des rapports HID volatils sont émis ; rien n'est flashé, rien n'est écrit
-// dans le stockage d'Input.
+// Original reimplementation of the observed format, documented in
+// docs/research/hid-lighting-protocol.md. Nothing persistent is written: only
+// volatile HID reports are emitted; nothing is flashed, nothing goes into
+// Input's storage.
//
-// Contention : l'app ChatGPT pousse sa propre configuration toutes les 35 à
-// 40 s et la dernière écriture gagne. Sans --hold, un état posé ici peut être
-// recouvert ; avec --hold, toute poussée étrangère détectée déclenche une
-// réapplication immédiate (plus un filet de sécurité périodique).
+// Contention: the ChatGPT app pushes its own configuration every 35 to 40s and
+// the last write wins. Without --hold, a state set here can be overwritten;
+// with --hold, any detected foreign push triggers an immediate reapply (plus a
+// periodic safety net).
import { spawn } from "node:child_process";
import { promises as fs, watch } from "node:fs";
@@ -35,35 +35,35 @@ const stateDir =
process.env.CLAUDE_THREAD_STATUS_DIR || path.join(os.homedir(), ".claude", "thread-status");
const slotsPath = path.join(stateDir, "slots.json");
-// Garde-temps contre les répétitions de touche du firmware.
+// Guard time against the firmware's key repeats.
const FOCUS_DEBOUNCE_MS = 300;
const HOLD_SAFETY_MS = 10000;
function usage() {
- return `Usage: node scripts/lighting.mjs [options]
-
-Commandes
- list [--json] énumère les interfaces HID vendeur du Codex Micro
- probe [--delay=ms] vérifie le canal (sys.version) puis allume les six
- touches une par une pour confirmer le mapping
- set slot <1-${SLOT_COUNT}> <#RRGGBB> [opts] couleur/effet d'une touche Agent
- set all <#RRGGBB> [opts] même réglage sur les six touches
- set zones --keys=#RRGGBB --ambient=#RRGGBB [opts] les deux zones globales
- watch [--hold] [--focus] pousse les couleurs d'état de slots.json en continu
- --focus : un appui sur une touche Agent va à sa session
- listen journalise touches et joystick (v.oai.hid / v.oai.rad)
- off éteint les six touches Agent
+ return `Usage: node scripts/lighting.mjs [options]
+
+Commands
+ list [--json] list the Codex Micro vendor HID interfaces
+ probe [--delay=ms] check the channel (sys.version), then light the six
+ keys one by one to confirm the mapping
+ set slot <1-${SLOT_COUNT}> <#RRGGBB> [opts] colour/effect of one Agent key
+ set all <#RRGGBB> [opts] same setting on all six keys
+ set zones --keys=#RRGGBB --ambient=#RRGGBB [opts] the two global zones
+ watch [--hold] [--focus] push slots.json state colours continuously
+ --focus: pressing an Agent key goes to its session
+ listen log key and joystick events (v.oai.hid / v.oai.rad)
+ off turn the six Agent keys off
Options
- --effect=nom ${Object.keys(EFFECTS).join(", ")}
- --brightness=0..1 intensité (0 = éteint, 1 = plein)
- --speed=0..1 vitesse d'effet
- --magic=n paramètre magic de zone (défaut 1)
- --hold réapplique dès qu'une écriture étrangère est détectée
- --path=chemin interface HID précise (défaut : première trouvée)
-
-Contention : l'app ChatGPT repousse sa configuration toutes les 35 à 40 s et la
-dernière écriture gagne. Sans --hold, l'état posé ici peut être recouvert.
+ --effect=name ${Object.keys(EFFECTS).join(", ")}
+ --brightness=0..1 intensity (0 = off, 1 = full)
+ --speed=0..1 effect speed
+ --magic=n zone magic parameter (default 1)
+ --hold reapply as soon as a foreign write is detected
+ --path=path specific HID interface (default: first one found)
+
+Contention: the ChatGPT app pushes its own configuration every 35 to 40s and the
+last write wins. Without --hold, the state set here can be overwritten.
`;
}
@@ -87,7 +87,7 @@ function parseUnit(name, value, fallback) {
if (value === undefined) return fallback;
const parsed = Number(value);
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
- throw new Error(`--${name} attendu entre 0 et 1 : ${value}`);
+ throw new Error(`--${name} expected between 0 and 1: ${value}`);
}
return parsed;
}
@@ -96,7 +96,7 @@ function parseEffect(value) {
if (value === undefined) return EFFECTS.solid;
const effect = EFFECTS[value];
if (effect === undefined) {
- throw new Error(`--effect inconnu : ${value} (${Object.keys(EFFECTS).join(", ")})`);
+ throw new Error(`unknown --effect: ${value} (${Object.keys(EFFECTS).join(", ")})`);
}
return effect;
}
@@ -119,7 +119,7 @@ async function commandList(flags) {
return;
}
if (interfaces.length === 0) {
- return fail("Aucune interface vendeur (VID 0x303a, usage 0xFF00). Clavier connecté ?");
+ return fail("No vendor interface (VID 0x303a, usage 0xFF00). Is the keyboard plugged in?");
}
for (const device of interfaces) {
process.stdout.write(
@@ -140,7 +140,7 @@ async function commandProbe(flags) {
if (!session) return;
session.onNotification(METHODS.notifyHid, (params) => {
- process.stdout.write(` [touche] ${JSON.stringify(params)}\n`);
+ process.stdout.write(` [key] ${JSON.stringify(params)}\n`);
});
session.onNotification(METHODS.notifyJoystick, (params) => {
process.stdout.write(` [joystick] ${JSON.stringify(params)}\n`);
@@ -148,15 +148,15 @@ async function commandProbe(flags) {
try {
const version = await session.call("sys.version");
- process.stdout.write(`✔ canal RPC fonctionnel — sys.version : ${JSON.stringify(version.result ?? version)}\n`);
+ process.stdout.write(`✔ RPC channel works — sys.version: ${JSON.stringify(version.result ?? version)}\n`);
} catch (error) {
await session.close();
- return fail(`✘ pas de réponse à sys.version : ${error.message}`);
+ return fail(`✘ no answer to sys.version: ${error.message}`);
}
process.stdout.write(
- `\nAllumage des six emplacements, un par un (thread id → touche attendue).\n` +
- `Noter toute divergence : la table SLOT_THREAD_IDS de scripts/lib/hid-lighting.mjs sera figée d'après cette observation.\n`,
+ `\nLighting the six slots one by one (thread id → expected key).\n` +
+ `Note any divergence: the SLOT_THREAD_IDS table in scripts/lib/hid-lighting.mjs is frozen from this observation.\n`,
);
for (let index = 0; index < SLOT_COUNT; index += 1) {
const entries = SLOT_THREAD_IDS.map((id, other) =>
@@ -170,15 +170,15 @@ async function commandProbe(flags) {
await session.call(METHODS.threadsLighting, entries);
} catch (error) {
await session.close();
- return fail(`✘ échec d'écriture thstatus : ${error.message}`);
+ return fail(`✘ thstatus write failed: ${error.message}`);
}
process.stdout.write(
- ` thread ${SLOT_THREAD_IDS[index]} allumé → emplacement ${index + 1} attendu (${SLOT_CONTROLS[index]})\n`,
+ ` thread ${SLOT_THREAD_IDS[index]} lit → slot ${index + 1} expected (${SLOT_CONTROLS[index]})\n`,
);
await wait(Number.isNaN(delay) ? 2000 : delay);
}
await session.call(METHODS.threadsLighting, allOffParams());
- process.stdout.write(`✔ sonde terminée, touches éteintes.\n`);
+ process.stdout.write(`✔ probe finished, keys turned off.\n`);
await session.close();
}
@@ -186,7 +186,7 @@ async function commandProbe(flags) {
function holdNote(flags) {
if (!flags.hold) {
- process.stdout.write(` (sans --hold, l'app ChatGPT peut recouvrir cet état toutes les 35 à 40 s)\n`);
+ process.stdout.write(` (without --hold, the ChatGPT app can overwrite this state every 35 to 40s)\n`);
}
}
@@ -196,7 +196,7 @@ async function commandSet(flags, positional) {
const keys = flags.keys;
const ambient = flags.ambient;
if (keys === undefined || ambient === undefined || keys === true || ambient === true) {
- return fail("set zones exige --keys=#RRGGBB et --ambient=#RRGGBB (rgbcfg décrit les deux zones d'un coup).");
+ return fail("set zones requires --keys=#RRGGBB and --ambient=#RRGGBB (rgbcfg describes both zones at once).");
}
const side = {
effect: parseEffect(flags.effect),
@@ -219,7 +219,7 @@ async function commandSet(flags, positional) {
let entries;
try {
if (target === "all") {
- if (!color) return fail("set all exige une couleur #RRGGBB.");
+ if (!color) return fail("set all requires a #RRGGBB colour.");
const base = {
color,
brightness: parseUnit("brightness", flags.brightness, 1),
@@ -230,9 +230,9 @@ async function commandSet(flags, positional) {
} else if (target === "slot") {
const slot = Number.parseInt(positional[1] ?? "", 10);
if (!Number.isInteger(slot) || slot < 1 || slot > SLOT_COUNT) {
- return fail(`Emplacement attendu entre 1 et ${SLOT_COUNT}.`);
+ return fail(`Expected a slot between 1 and ${SLOT_COUNT}.`);
}
- if (!positional[2]) return fail("set slot exige une couleur #RRGGBB.");
+ if (!positional[2]) return fail("set slot requires a #RRGGBB colour.");
const base = {
id: SLOT_THREAD_IDS[slot - 1],
color: positional[2],
@@ -242,9 +242,9 @@ async function commandSet(flags, positional) {
if (flags.speed !== undefined) base.speed = parseUnit("speed", flags.speed, 0.5);
entries = [threadEntry(base)];
} else {
- return fail(`Cible inconnue : ${target ?? "(absente)"}\n\n${usage()}`);
+ return fail(`Unknown target: ${target ?? "(missing)"}\n\n${usage()}`);
}
- colorToInt(entries[0].c); // valide avant d'ouvrir le périphérique
+ colorToInt(entries[0].c); // validate before opening the device
} catch (error) {
return fail(error.message);
}
@@ -256,7 +256,7 @@ async function applyOnce(flags, method, params, label) {
const session = await openSession(flags, {
onForeignWrite: flags.hold
? async (foreignMethod) => {
- process.stdout.write(` ! poussée étrangère (${foreignMethod}) — réapplication\n`);
+ process.stdout.write(` ! foreign write (${foreignMethod}) — reapplying\n`);
await session.call(method, current).catch(() => {});
}
: undefined,
@@ -265,18 +265,18 @@ async function applyOnce(flags, method, params, label) {
try {
await session.call(method, current);
- process.stdout.write(`✔ ${label} appliqué.\n`);
+ process.stdout.write(`✔ ${label} applied.\n`);
holdNote(flags);
} catch (error) {
await session.close();
- return fail(`✘ échec d'écriture : ${error.message}`);
+ return fail(`✘ write failed: ${error.message}`);
}
if (!flags.hold) return session.close();
const timer = setInterval(() => {
session.call(method, current).catch(() => {});
}, HOLD_SAFETY_MS);
- process.stdout.write(` maintien actif (--hold), filet de sécurité ${HOLD_SAFETY_MS / 1000} s. Ctrl-C pour quitter.\n`);
+ process.stdout.write(` hold active (--hold), safety net every ${HOLD_SAFETY_MS / 1000}s. Ctrl-C to quit.\n`);
process.on("SIGINT", async () => {
clearInterval(timer);
await session.close();
@@ -288,7 +288,7 @@ async function applyOnce(flags, method, params, label) {
async function readSlots() {
const raw = JSON.parse(await fs.readFile(slotsPath, "utf8"));
- if (!Array.isArray(raw.slots)) throw new Error("slots.json sans tableau slots");
+ if (!Array.isArray(raw.slots)) throw new Error("slots.json has no slots array");
return raw.slots;
}
@@ -298,7 +298,7 @@ async function commandWatch(flags) {
rows = await readSlots();
} catch {
return fail(
- `${slotsPath} illisible. Lancer d'abord \`npm run thread-status -- watch\` pour produire l'état.`,
+ `${slotsPath} is unreadable. Run \`npm run thread-status -- watch\` first to produce the state.`,
);
}
@@ -306,24 +306,24 @@ async function commandWatch(flags) {
const apply = async (reason) => {
if (!lastApplied) return;
await session.call(METHODS.threadsLighting, lastApplied).catch((error) => {
- process.stderr.write(` ! écriture impossible (${reason}) : ${error.message}\n`);
+ process.stderr.write(` ! write failed (${reason}): ${error.message}\n`);
});
};
const session = await openSession(flags, {
onForeignWrite: flags.hold
? (foreignMethod) => {
- process.stdout.write(` ! poussée étrangère (${foreignMethod}) — réapplication\n`);
+ process.stdout.write(` ! foreign write (${foreignMethod}) — reapplying\n`);
void apply("hold");
}
: undefined,
});
if (!session) return;
- // Les six touches Agent émettent `v.oai.hid` avec `k` valant `AG00` à `AG05` :
- // le clavier désigne lui-même l'emplacement pressé, sur le canal HID déjà
- // ouvert. Aucun raccourci global, aucune API native, aucune autorisation macOS.
- // `act` vaut 1 à l'appui et 0 au relâchement.
+ // The six Agent keys emit `v.oai.hid` with `k` set to `AG00` through `AG05`:
+ // the keyboard itself names the slot that was pressed, over the HID channel
+ // already open. No global shortcut, no native API, no macOS permission.
+ // `act` is 1 on press and 0 on release.
if (flags.focus) {
let lastPress = 0;
const focusScript = fileURLToPath(new URL("./thread-status.mjs", import.meta.url));
@@ -341,19 +341,19 @@ async function commandWatch(flags) {
child.stdout.on("data", (chunk) => (output += chunk));
child.stderr.on("data", (chunk) => (output += chunk));
child.on("close", () => {
- // Toutes les lignes, pas seulement la première : pour une session fermée,
- // `focus` répond sur deux lignes et c'est la seconde qui porte la commande
- // de reprise. N'en afficher qu'une revenait à masquer l'essentiel.
+ // Every line, not only the first: for a closed session, `focus` answers
+ // over two lines and the second one carries the resume command. Showing
+ // only one hid the part that mattered.
const lines = output.trim().split("\n").filter((line) => line.trim());
- const stamp = `${new Date().toISOString()} [touche ${slot}] `;
+ const stamp = `${new Date().toISOString()} [key ${slot}] `;
process.stdout.write(
lines.length
? `${stamp}${lines[0]}\n${lines.slice(1).map((line) => `${" ".repeat(stamp.length)}${line.trim()}\n`).join("")}`
- : `${stamp}sans retour\n`,
+ : `${stamp}no output\n`,
);
});
});
- process.stdout.write("Appui sur une touche Agent → navigation vers sa session.\n");
+ process.stdout.write("Pressing an Agent key navigates to its session.\n");
}
const push = async (nextRows, reason) => {
@@ -378,15 +378,15 @@ async function commandWatch(flags) {
try {
await push(await readSlots(), "slots.json");
} catch (error) {
- process.stderr.write(` ! relecture : ${error.message}\n`);
+ process.stderr.write(` ! re-read: ${error.message}\n`);
}
}, 100);
});
- // Filet de sécurité : en --hold, réapplication périodique quoi qu'il arrive.
- const timer = flags.hold ? setInterval(() => void apply("filet"), HOLD_SAFETY_MS) : null;
+ // Safety net: under --hold, reapply periodically whatever happens.
+ const timer = flags.hold ? setInterval(() => void apply("safety-net"), HOLD_SAFETY_MS) : null;
- process.stdout.write(`Surveillance de ${slotsPath}. Ctrl-C pour quitter.\n`);
+ process.stdout.write(`Watching ${slotsPath}. Ctrl-C to quit.\n`);
process.on("SIGINT", async () => {
watcher.close();
if (timer) clearInterval(timer);
@@ -401,12 +401,12 @@ async function commandListen(flags) {
const session = await openSession(flags);
if (!session) return;
session.onNotification(METHODS.notifyHid, (params) => {
- process.stdout.write(`${new Date().toISOString()} touche ${JSON.stringify(params)}\n`);
+ process.stdout.write(`${new Date().toISOString()} key ${JSON.stringify(params)}\n`);
});
session.onNotification(METHODS.notifyJoystick, (params) => {
process.stdout.write(`${new Date().toISOString()} joystick ${JSON.stringify(params)}\n`);
});
- process.stdout.write(`Écoute des notifications du périphérique. Ctrl-C pour quitter.\n`);
+ process.stdout.write(`Listening for device notifications. Ctrl-C to quit.\n`);
process.on("SIGINT", async () => {
await session.close();
process.exit(0);
@@ -420,15 +420,15 @@ async function commandOff(flags) {
if (!session) return;
try {
await session.call(METHODS.threadsLighting, allOffParams());
- process.stdout.write(`✔ six emplacements éteints.\n`);
+ process.stdout.write(`✔ six slots turned off.\n`);
} catch (error) {
await session.close();
- return fail(`✘ échec d'écriture : ${error.message}`);
+ return fail(`✘ write failed: ${error.message}`);
}
return session.close();
}
-// --- entrée ------------------------------------------------------------------
+// --- entry point --------------------------------------------------------------
async function main(argv) {
const [command, ...rest] = argv;
@@ -445,7 +445,7 @@ async function main(argv) {
if (command === "watch") return await commandWatch(flags);
if (command === "listen") return await commandListen(flags);
if (command === "off") return await commandOff(flags);
- return fail(`Commande inconnue : ${command}\n\n${usage()}`);
+ return fail(`Unknown command: ${command}\n\n${usage()}`);
} catch (error) {
if (error instanceof DeviceError) return fail(error.message);
throw error;
diff --git a/scripts/prepare-gui.mjs b/scripts/prepare-gui.mjs
index 13ee590..259c04e 100644
--- a/scripts/prepare-gui.mjs
+++ b/scripts/prepare-gui.mjs
@@ -9,7 +9,7 @@ const guiDirectory = resolve(repositoryRoot, "prototype");
try {
const result = ensureGuiDependencies(guiDirectory);
- console.log(result.installed ? "OK: dépendances GUI installées" : "OK: dépendances GUI déjà disponibles");
+ console.log(result.installed ? "OK: GUI dependencies installed" : "OK: GUI dependencies already available");
} catch (error) {
console.error(error.message);
process.exitCode = error.exitCode ?? 1;
diff --git a/scripts/thread-status.mjs b/scripts/thread-status.mjs
index 4e95054..886e362 100644
--- a/scripts/thread-status.mjs
+++ b/scripts/thread-status.mjs
@@ -1,16 +1,16 @@
#!/usr/bin/env node
-// Compagnon local des six touches Agent. Réconcilie deux sources officielles :
+// Local companion for the six Agent keys. Reconciles two official sources:
//
-// - `claude agents --json` pour le roster des sessions vivantes ;
-// - le journal NDJSON écrit par le plugin thread-status pour leurs états.
+// - `claude agents --json` for the roster of live sessions;
+// - the NDJSON journal written by the thread-status plugin for their states.
//
-// Il n'écrit rien sur le périphérique. Sa sortie est `slots.json`, qui sert de
-// couture pour un futur DeviceAdapter : tant que le protocole RGB du Codex Micro
-// n'est pas mesuré, le compagnon s'arrête à ce fichier et à l'affichage.
+// It writes nothing to the device. Its output is `slots.json`, the seam the
+// DeviceAdapter consumes: this companion stops at that file and at the display,
+// and scripts/lighting.mjs is what pushes colours to the keyboard.
//
-// Voir docs/research/thread-status-feasibility.md pour ce qui est mesuré et ce
-// qui reste ouvert.
+// See docs/research/thread-status-feasibility.md for what is measured and what
+// is still open.
import { spawnSync } from "node:child_process";
import { existsSync, promises as fs, readdirSync, watch } from "node:fs";
@@ -29,8 +29,8 @@ import {
ttyDevice,
} from "./lib/thread-slots.mjs";
-// Contrat de chemin partagé avec thread-status/bin/emit.mjs, qui reste
-// volontairement sans dépendance.
+// Path contract shared with thread-status/bin/emit.mjs, which deliberately
+// stays dependency-free.
const stateDir =
process.env.CLAUDE_THREAD_STATUS_DIR || path.join(os.homedir(), ".claude", "thread-status");
const journalPath = path.join(stateDir, "events.ndjson");
@@ -38,9 +38,9 @@ const snapshotPath = path.join(stateDir, "slots.json");
const DEFAULT_INTERVAL_MS = 2000;
-// Détection du terminal hôte par le nom de l'exécutable remonté par `ps`. Seuls
-// iTerm2 et Terminal sont pilotables ici ; les autres sont nommés pour que le
-// message d'erreur soit exploitable au lieu d'être générique.
+// Host terminal detected from the executable name reported by `ps`. Only iTerm2
+// and Terminal are scriptable here; the others are named so the error message is
+// actionable instead of generic.
const TERMINAL_APPS = [
{ basename: "iTerm2", name: "iTerm2", driver: "iterm" },
{ basename: "Terminal", name: "Terminal", driver: "terminal" },
@@ -54,16 +54,16 @@ const TERMINAL_APPS = [
];
function usage() {
- return `Usage: node scripts/thread-status.mjs
+ return `Usage: node scripts/thread-status.mjs
-Commandes
- watch [--interval=ms] réconcilie en continu et écrit slots.json
- status [--json] affiche l'état courant des six emplacements
- focus <1-${SLOT_COUNT}> va à la session de cet emplacement
- doctor vérifie les prérequis et les sources
+Commands
+ watch [--interval=ms] reconcile continuously and write slots.json
+ status [--json] show the current state of the six slots
+ focus <1-${SLOT_COUNT}> go to the session on that slot
+ doctor check prerequisites and sources
-Environnement
- CLAUDE_THREAD_STATUS_DIR répertoire d'état (défaut ~/.claude/thread-status)
+Environment
+ CLAUDE_THREAD_STATUS_DIR state directory (default ~/.claude/thread-status)
`;
}
@@ -90,7 +90,7 @@ function resolveClaudeBinary() {
candidates.push(path.join(bundled, version, "claude.app", "Contents", "MacOS", "claude"));
}
} catch {
- // Installation non groupée avec Claude Desktop : les autres candidats suffisent.
+ // Not bundled with Claude Desktop: the other candidates are enough.
}
return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null;
@@ -99,15 +99,15 @@ function resolveClaudeBinary() {
function readRoster(binary) {
const result = spawnSync(binary, ["agents", "--json"], { encoding: "utf8", timeout: 15000 });
if (result.status !== 0) {
- throw new Error(`\`claude agents --json\` a échoué : ${(result.stderr || result.stdout || "").trim()}`);
+ throw new Error(`\`claude agents --json\` failed: ${(result.stderr || result.stdout || "").trim()}`);
}
const parsed = JSON.parse(result.stdout);
- if (!Array.isArray(parsed)) throw new Error("`claude agents --json` n'a pas renvoyé un tableau.");
+ if (!Array.isArray(parsed)) throw new Error("`claude agents --json` did not return an array.");
return parsed;
}
-// Une seule lecture de la table des processus : le tty de la session et, en
-// remontant les parents, le terminal qui l'héberge.
+// One single read of the process table: the session's tty and, by walking up
+// the parents, the terminal hosting it.
function processTable() {
const result = spawnSync("ps", ["-Ao", "pid=,ppid=,tty=,comm="], { encoding: "utf8" });
const table = new Map();
@@ -144,18 +144,18 @@ function enrichRoster(roster) {
});
}
-// --- état persistant ---------------------------------------------------------
+// --- persistent state --------------------------------------------------------
-// Empreinte de ce qu'un consommateur observe. `slots.json` est la couture du
-// futur DeviceAdapter : s'il est réécrit à chaque tick, un `watch` qui le suit
-// pousserait des rapports HID en continu alors que rien n'a bougé. On compare
-// donc les six entrées avant d'écrire.
+// Fingerprint of what a consumer actually observes. `slots.json` is the
+// DeviceAdapter's seam: rewritten on every tick, a `watch` following it would
+// push HID reports continuously while nothing moved. So the six entries are
+// compared before writing.
//
-// `pending`, `dropped`, `overflow`, `journalOffset` et l'horodatage sont exclus
-// de l'empreinte : ils bougent sans que l'affichage change. Le curseur de
-// journal n'est donc persisté qu'à l'occasion d'un vrai changement, et un
-// redémarrage rejoue au pire la queue d'événements depuis ce point — des
-// événements qui, par construction, n'avaient rien changé.
+// `pending`, `dropped`, `overflow`, `journalOffset` and the timestamp are left
+// out of the fingerprint: they move without the display changing. The journal
+// cursor is therefore only persisted on a real change, and a restart replays at
+// worst the tail of events from that point — events which, by construction, had
+// changed nothing.
function publishedFingerprint(snapshot) {
return JSON.stringify(snapshot.slots);
}
@@ -200,9 +200,9 @@ async function saveState(snapshot, journalOffset) {
return true;
}
-// Lit les lignes ajoutées depuis le dernier passage. Une taille inférieure à
-// l'offset signale une rotation du journal : on repart de zéro plutôt que de
-// lire au milieu d'un enregistrement.
+// Reads the lines appended since the last pass. A size below the offset signals
+// a journal rotation: start over from zero rather than read from the middle of
+// a record.
async function readJournalSince(offset) {
let size = 0;
try {
@@ -219,7 +219,7 @@ async function readJournalSince(offset) {
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, offset);
const text = buffer.toString("utf8");
- // Une dernière ligne incomplète est laissée pour le prochain passage.
+ // A trailing incomplete line is left for the next pass.
const complete = text.endsWith("\n") ? text : text.slice(0, text.lastIndexOf("\n") + 1);
const events = [];
for (const line of complete.split("\n")) {
@@ -227,7 +227,7 @@ async function readJournalSince(offset) {
try {
events.push(JSON.parse(line));
} catch {
- // Ligne tronquée par une rotation concurrente : ignorée.
+ // Line truncated by a concurrent rotation: ignored.
}
}
return { events, offset: offset + Buffer.byteLength(complete, "utf8") };
@@ -245,8 +245,8 @@ async function reconcile(binary, state) {
snapshot = roster.snapshot;
let changed = roster.changed;
- // Les hooks après le roster : le roster ouvre l'emplacement, les hooks y
- // posent l'état, y compris celui mis en attente avant l'apparition.
+ // Hooks after the roster: the roster opens the slot, the hooks set the state
+ // on it, including the one held pending before the session appeared.
for (const event of journal.events) {
const applied = applyHookEvent(snapshot, event, event.ts ?? now);
snapshot = applied.snapshot;
@@ -256,15 +256,15 @@ async function reconcile(binary, state) {
return { snapshot, journalOffset: journal.offset, changed, notes: roster.notes };
}
-// --- affichage ---------------------------------------------------------------
+// --- display -----------------------------------------------------------------
const STATE_LABELS = {
- [STATES.free]: "libre",
- [STATES.idle]: "au repos",
- [STATES.running]: "en cours",
- [STATES.blocked]: "intervention",
- [STATES.done]: "terminé",
- [STATES.ended]: "fermé",
+ [STATES.free]: "free",
+ [STATES.idle]: "idle",
+ [STATES.running]: "running",
+ [STATES.blocked]: "needs you",
+ [STATES.done]: "done",
+ [STATES.ended]: "closed",
};
function dot(color) {
@@ -299,37 +299,37 @@ function renderTable(snapshot) {
function runAppleScript(script) {
const result = spawnSync("osascript", ["-e", script], { encoding: "utf8", timeout: 15000 });
const output = (result.stdout || result.stderr || "").trim();
- // Un AppleEvent qui expire — `-1712`, ou `osascript` tué par le timeout —
- // signifie presque toujours que le consentement Automation n'a pas été accordé
- // au shell appelant : macOS n'affiche pas toujours l'invite et laisse
- // simplement l'événement expirer. Le confondre avec « fenêtre introuvable »
- // envoie chercher le problème du côté du terminal, où il n'est pas.
+ // An AppleEvent that times out — `-1712`, or `osascript` killed by the
+ // timeout — almost always means Automation consent was not granted to the
+ // calling shell: macOS does not always show the prompt and simply lets the
+ // event expire. Confusing it with "window not found" sends you looking at the
+ // terminal, which is not where the problem is.
if (result.error?.code === "ETIMEDOUT" || result.signal || output.includes("-1712")) {
return { ok: false, reason: "automation-consent", output };
}
return { ok: result.status === 0, output };
}
-// Le tty vient de `ps` : on le revalide avant interpolation plutôt que de faire
-// confiance à sa provenance.
+// The tty comes from `ps`: revalidate it before interpolation rather than trust
+// where it came from.
function focusTerminal(target) {
if (!/^\/dev\/tty[a-z0-9.]+$/i.test(target.tty)) {
- return { ok: false, output: `Chemin de terminal inattendu : ${target.tty}` };
+ return { ok: false, output: `unexpected terminal path: ${target.tty}` };
}
const app = TERMINAL_APPS.find((candidate) => candidate.name === target.app);
if (!app?.driver) {
return {
ok: false,
- output: `Terminal non piloté${target.app ? ` : ${target.app}` : ""}. Emplacement sur ${target.tty}.`,
+ output: `terminal not scriptable${target.app ? `: ${target.app}` : ""}. Slot is on ${target.tty}.`,
};
}
- // L'ordre des opérations décide du résultat quand les fenêtres se recouvrent.
- // `activate` d'abord remonte la fenêtre déjà frontale, et réordonner ensuite ne
- // tient pas : la cible reste sous la pile. On sélectionne donc l'onglet, on
- // remonte sa fenêtre en tête de l'ordre de profondeur, et on n'active l'app
- // qu'en dernier. Bénéfice secondaire : un `tty` introuvable ne vole plus le
- // focus pour rien, puisqu'on sort sans jamais activer.
+ // The order of operations decides the outcome when windows overlap.
+ // `activate` first raises the window that is already frontmost, and
+ // reordering afterwards does not hold: the target stays under the stack. So
+ // select the tab, bring its window to the front of the z-order, and only
+ // activate the app last. Side benefit: a `tty` that cannot be found no longer
+ // steals focus for nothing, since we return without ever activating.
if (app.driver === "iterm") {
return runAppleScript(`tell application "iTerm2"
repeat with w in windows
@@ -372,79 +372,79 @@ async function focus(slotNumber) {
switch (target.kind) {
case "empty":
- return fail(`Emplacement ${slotNumber} : libre.`);
+ return fail(`Slot ${slotNumber}: free.`);
case "terminal": {
const result = focusTerminal(target);
if (result.reason === "automation-consent") {
return fail(
- `Emplacement ${slotNumber} : ${target.app} n'a pas répondu à l'AppleEvent.\n` +
- " C'est le consentement Automation, pas le terminal. Accorder\n" +
- ` Réglages Système > Confidentialité et sécurité > Automatisation > ${target.app},\n` +
- " pour le terminal depuis lequel cette commande est lancée.",
+ `Slot ${slotNumber}: ${target.app} did not answer the AppleEvent.\n` +
+ " This is Automation consent, not the terminal. Grant\n" +
+ ` System Settings > Privacy & Security > Automation > ${target.app},\n` +
+ " for the terminal this command runs from.",
);
}
if (!result.ok || result.output === "not-found") {
- return fail(`Emplacement ${slotNumber} : ${result.output || "fenêtre introuvable"}.`);
+ return fail(`Slot ${slotNumber}: ${result.output || "window not found"}.`);
}
- process.stdout.write(`Emplacement ${slotNumber} : ${target.app} activé sur ${target.tty}.\n`);
+ process.stdout.write(`Slot ${slotNumber}: ${target.app} focused on ${target.tty}.\n`);
return undefined;
}
case "resume": {
- // La session est fermée : on n'en lance pas la reprise à la place de
- // l'utilisateur, on lui donne la commande exacte. Reprendre une session
- // vivante depuis un second terminal entrelacerait les deux transcripts.
+ // The session is closed: we do not resume it on the user's behalf, we hand
+ // them the exact command. Resuming a live session from a second terminal
+ // would interleave the two transcripts.
const where = target.cwd ? redactHome(target.cwd) : ".";
process.stdout.write(
- `Emplacement ${slotNumber} : session fermée. Reprise :\n cd ${where} && claude --resume ${target.sessionId}\n`,
+ `Slot ${slotNumber}: session closed. Resume it with:\n cd ${where} && claude --resume ${target.sessionId}\n`,
);
return undefined;
}
case "desktop": {
- // `claude://resume?session=` ouvre la session par son identifiant.
- // Comme `open -b`, le passage par le handler d'URL évite AppleScript et
- // n'exige donc aucun consentement Automation.
+ // `claude://resume?session=` opens the session by its id. Like
+ // `open -b`, going through the URL handler avoids AppleScript and
+ // therefore needs no Automation consent.
//
- // Le handler ne rend pas compte de l'issue : `open` sort en 0 dès que
- // l'URL est remise. Une session dont le transcript a disparu du disque
- // échoue côté application, sans que rien ne remonte ici.
+ // The handler reports nothing back: `open` exits 0 as soon as the URL is
+ // delivered. A session whose transcript has left the disk fails on the
+ // application side, with nothing surfacing here.
const opened = spawnSync("open", [target.url], { encoding: "utf8", timeout: 10000 });
if (opened.status !== 0) {
return fail(
- `Emplacement ${slotNumber} : ${target.url} n'a pas pu être ouvert : ${(opened.stderr || "").trim()}`,
+ `Slot ${slotNumber}: could not open ${target.url}: ${(opened.stderr || "").trim()}`,
);
}
process.stdout.write(
- `Emplacement ${slotNumber} : session ${target.sessionId.slice(0, 8)} demandée à Claude Desktop.\n`,
+ `Slot ${slotNumber}: session ${target.sessionId.slice(0, 8)} requested from Claude Desktop.\n`,
);
return undefined;
}
default: {
- // Session hébergée dont l'identifiant n'est pas un UUID : `claude://resume`
- // le refuserait. Faute de pouvoir sélectionner la bonne session, on met au
- // moins l'application au premier plan.
+ // Hosted session whose id is not a UUID: `claude://resume` would reject
+ // it. Unable to select the right session, at least bring the application
+ // to the front.
//
- // `open -b` évite AppleScript, donc n'exige aucun consentement Automation.
+ // `open -b` avoids AppleScript, so it needs no Automation consent.
const activated = spawnSync("open", ["-b", "com.anthropic.claudefordesktop"], {
encoding: "utf8",
timeout: 10000,
});
if (activated.status !== 0) {
return fail(
- `Emplacement ${slotNumber} : ${target.reason}\n` +
+ `Slot ${slotNumber}: ${target.reason}\n` +
` session ${target.sessionId}\n` +
- ` Claude Desktop n'a pas pu être activé : ${(activated.stderr || "").trim()}`,
+ ` Claude Desktop could not be activated: ${(activated.stderr || "").trim()}`,
);
}
process.stdout.write(
- `Emplacement ${slotNumber} : Claude Desktop activé. La session ${target.sessionId.slice(0, 8)} ` +
- "ne peut pas être sélectionnée : aucune route ne l'adresse.\n",
+ `Slot ${slotNumber}: Claude Desktop activated. Session ${target.sessionId.slice(0, 8)} ` +
+ "cannot be selected: no route addresses it.\n",
);
return undefined;
}
}
}
-// --- commandes ---------------------------------------------------------------
+// --- commands ----------------------------------------------------------------
async function commandStatus(binary, asJson) {
const state = await loadState();
@@ -459,7 +459,7 @@ async function commandStatus(binary, asJson) {
for (const note of result.notes) process.stdout.write(` ! ${note}\n`);
if (result.snapshot.overflow > 0) {
process.stdout.write(
- ` ! ${result.snapshot.overflow} session(s) sans emplacement depuis le démarrage : ${SLOT_COUNT} touches Agent, pas plus.\n`,
+ ` ! ${result.snapshot.overflow} session(s) left without a slot since startup: ${SLOT_COUNT} Agent keys, no more.\n`,
);
}
}
@@ -474,8 +474,8 @@ async function commandWatch(binary, intervalMs) {
try {
const result = await reconcile(binary, state);
state = { snapshot: result.snapshot, journalOffset: result.journalOffset };
- // Le rendu suit l'écriture, pas `result.changed` : seul un changement
- // réellement publié mérite une ligne à l'écran comme une poussée HID.
+ // The render follows the write, not `result.changed`: only a change that
+ // was actually published deserves a line on screen, or an HID push.
if (await saveState(result.snapshot, result.journalOffset)) {
process.stdout.write(`\n${new Date().toISOString()}\n${renderTable(result.snapshot)}\n`);
for (const note of result.notes) process.stdout.write(` ! ${note}\n`);
@@ -489,7 +489,7 @@ async function commandWatch(binary, intervalMs) {
await fs.mkdir(stateDir, { recursive: true });
await fs.appendFile(journalPath, "");
- // Le poll rattrape les hooks manqués, le watch donne la latence.
+ // The poll catches missed hooks, the watch gives the latency.
const timer = setInterval(tick, intervalMs);
const watcher = watch(journalPath, () => void tick());
process.on("SIGINT", () => {
@@ -498,30 +498,30 @@ async function commandWatch(binary, intervalMs) {
process.exit(0);
});
- process.stdout.write(`Journal : ${redactHome(journalPath)}\nSortie : ${redactHome(snapshotPath)}\n`);
+ process.stdout.write(`Journal: ${redactHome(journalPath)}\nOutput: ${redactHome(snapshotPath)}\n`);
await tick();
}
async function commandDoctor(binary) {
const checks = [];
- checks.push([Boolean(binary), `binaire claude : ${binary ? redactHome(binary) : "introuvable"}`]);
+ checks.push([Boolean(binary), `claude binary: ${binary ? redactHome(binary) : "not found"}`]);
let roster = [];
if (binary) {
try {
roster = readRoster(binary);
- checks.push([true, `claude agents --json : ${roster.length} session(s) vivante(s)`]);
+ checks.push([true, `claude agents --json: ${roster.length} live session(s)`]);
} catch (error) {
- checks.push([false, `claude agents --json : ${error.message}`]);
+ checks.push([false, `claude agents --json: ${error.message}`]);
}
}
try {
await fs.mkdir(stateDir, { recursive: true });
await fs.access(stateDir);
- checks.push([true, `répertoire d'état accessible : ${redactHome(stateDir)}`]);
+ checks.push([true, `state directory reachable: ${redactHome(stateDir)}`]);
} catch (error) {
- checks.push([false, `répertoire d'état : ${error.message}`]);
+ checks.push([false, `state directory: ${error.message}`]);
}
const journal = await readJournalSince(0);
@@ -529,16 +529,16 @@ async function commandDoctor(binary) {
checks.push([
journal.events.length > 0,
journal.events.length > 0
- ? `journal : ${journal.events.length} événement(s), dernier ${last.event} il y a ${Math.round((Date.now() - last.ts) / 1000)} s`
- : "journal vide : le plugin n'a jamais émis. Charger thread-status/ puis lancer un tour.",
+ ? `journal: ${journal.events.length} event(s), last ${last.event} ${Math.round((Date.now() - last.ts) / 1000)}s ago`
+ : "journal empty: the plugin has never emitted. Load thread-status/ then run a turn.",
]);
const withTty = enrichRoster(roster).filter((row) => row.tty);
checks.push([
true,
- `navigation : ${withTty.length}/${roster.length} session(s) dans un terminal identifiable` +
+ `navigation: ${withTty.length}/${roster.length} session(s) in an identifiable terminal` +
(withTty.length < roster.length
- ? " — les autres sont hébergées par Claude Desktop ou un IDE, sans route documentée"
+ ? " — the others are hosted by Claude Desktop or an IDE, reached through claude://resume"
: ""),
]);
@@ -546,7 +546,7 @@ async function commandDoctor(binary) {
if (checks.some(([ok]) => !ok)) process.exitCode = 1;
}
-// --- entrée ------------------------------------------------------------------
+// --- entry point --------------------------------------------------------------
async function main(argv) {
const [command, ...rest] = argv;
@@ -558,14 +558,14 @@ async function main(argv) {
if (command === "focus") {
const slotNumber = Number.parseInt(rest[0] ?? "", 10);
if (!Number.isInteger(slotNumber) || slotNumber < 1 || slotNumber > SLOT_COUNT) {
- return fail(`Emplacement attendu entre 1 et ${SLOT_COUNT}.`);
+ return fail(`Expected a slot between 1 and ${SLOT_COUNT}.`);
}
return focus(slotNumber);
}
const binary = resolveClaudeBinary();
if (command === "doctor") return commandDoctor(binary);
- if (!binary) return fail("Binaire `claude` introuvable. Voir `doctor`.");
+ if (!binary) return fail("`claude` binary not found. See `doctor`.");
if (command === "status") return commandStatus(binary, rest.includes("--json"));
if (command === "watch") {
@@ -574,7 +574,7 @@ async function main(argv) {
return commandWatch(binary, Number.isInteger(parsed) && parsed >= 250 ? parsed : DEFAULT_INTERVAL_MS);
}
- return fail(`Commande inconnue : ${command}\n\n${usage()}`);
+ return fail(`Unknown command: ${command}\n\n${usage()}`);
}
main(process.argv.slice(2)).catch((error) => {
diff --git a/shared/input-profile.mjs b/shared/input-profile.mjs
index e9fa3de..2fa6fbf 100644
--- a/shared/input-profile.mjs
+++ b/shared/input-profile.mjs
@@ -8,9 +8,9 @@ const MODIFIER_KEYCODES = {
Control: "KC_LCTL",
};
-// Touches finales autorisées pour un raccourci. Retour/Entrée, Suppression et
-// Retour arrière sont volontairement absents : un appui accidentel ne doit
-// jamais envoyer, approuver ou détruire quoi que ce soit.
+// Final keys allowed in a shortcut. Return/Enter, Delete and Backspace are
+// deliberately absent: an accidental press must never send, approve or destroy
+// anything.
const FINAL_KEYCODES = {
...Object.fromEntries(
Array.from({ length: 26 }, (_, index) => {
@@ -52,8 +52,8 @@ const MODIFIER_KEY_BY_KEYCODE = Object.fromEntries(
const FORBIDDEN_KEYS = Object.freeze(["Enter", "Return", "Delete", "Backspace"]);
-// Une touche imprimable seule taperait du texte dans la conversation : elle
-// n'est acceptée qu'accompagnée d'un modificateur.
+// A printable key on its own would type text into the conversation: it is only
+// accepted together with a modifier.
const PRINTABLE_KEYS = new Set([
...Array.from({ length: 26 }, (_, index) => String.fromCharCode(65 + index)),
...Array.from({ length: 10 }, (_, digit) => String(digit)),
@@ -65,9 +65,9 @@ const PRINTABLE_KEYS = new Set([
"Minus",
]);
-// Les douze keycaps programmables sont répartis sur quatre rangées. La
-// première cellule de la dernière rangée est le capteur de changement de
-// layer : elle est volontairement absente de cette table et reste intacte.
+// The twelve programmable keycaps are spread over four rows. The first cell of
+// the last row is the layer-change sensor: it is deliberately absent from this
+// table and stays untouched.
const KEY_CONTROL_LOCATIONS = Object.freeze({
"key-9": { row: 0, column: 0 },
"key-10": { row: 0, column: 1 },
@@ -89,10 +89,10 @@ const ENCODER_PRESS_CONTROL = "key-13";
const DEFAULT_MAPPING = {
joystick: "navigation",
- // La molette est en mode Effort par défaut : c'est le geste distinctif de cette
- // carte pour Claude, et il est calibré et documenté (voir
- // docs/research/effort-wheel-calibration.md). Les autres modes restent
- // disponibles dans le GUI, le défilement compris.
+ // The wheel is in Effort mode by default: that is this board's distinctive
+ // gesture for Claude, and it is calibrated and documented (see
+ // docs/research/effort-wheel-calibration.md). The other modes stay available
+ // in the GUI, scrolling included.
wheel: "effort",
"key-9": "none",
"key-10": "none",
@@ -148,10 +148,10 @@ const ACTION_DEFINITIONS = {
type: "direct",
key: "Escape",
},
- // Cycle entre les sessions du Code tab de Claude Desktop. La documentation
- // précise que ce raccourci utilise Control sur toutes les plateformes,
- // contrairement aux autres. Il n'existe aucun raccourci pour choisir une
- // session par son rang : seul le cycle est adressable.
+ // Cycles through the sessions of Claude Desktop's Code tab. The documentation
+ // states that this shortcut uses Control on every platform, unlike the
+ // others. There is no shortcut to pick a session by its rank: only cycling is
+ // addressable.
nextSession: {
name: "Claude Next Session",
type: "shortcut",
@@ -162,7 +162,7 @@ const ACTION_DEFINITIONS = {
type: "shortcut",
keys: ["Control", "Shift", "Tab"],
},
- // Ouvre le menu d'effort, où les chiffres 1 à 9 sélectionnent une entrée.
+ // Opens the effort menu, where the digits 1 to 9 select an entry.
effortMenu: {
name: "Claude Effort Menu",
type: "shortcut",
@@ -229,45 +229,44 @@ const ACTION_DEFINITIONS = {
},
};
-// Attente laissée au sélecteur d'effort pour apparaître, en millisecondes.
+// Time given to the effort picker to appear, in milliseconds.
//
-// Input ne documente pas si `delay` s'applique avant ou après son étape, et
-// aucune source ne permet de le trancher : le champ est transmis verbatim au
-// firmware, qui seul l'interprète. La macro contourne la question par sa forme
-// plutôt que par une mesure. Toute l'attente est posée sur la libération de ⌘ et
-// la flèche reçoit 0, ce qui rend les deux lectures équivalentes :
+// Input does not document whether `delay` applies before or after its step, and
+// no source settles it: the field is passed verbatim to the firmware, which
+// alone interprets it. The macro sidesteps the question by its shape rather
+// than by a measurement. All of the wait is placed on the ⌘ release and the
+// arrow gets 0, which makes both readings equivalent:
//
-// - lecture « après » : ⌘ relâché, attente, flèche -> sélecteur : ce délai
-// - lecture « avant » : attente, ⌘ relâché, flèche -> sélecteur : ce délai
+// - "after" reading: ⌘ released, wait, arrow -> picker gets this delay
+// - "before" reading: wait, ⌘ released, arrow -> picker gets this delay
//
-// Même marge et même durée totale dans les deux cas. Ne pas répartir cette
-// attente sur les deux étapes : cela double le délai d'ouverture sans rien
-// garantir de plus.
+// Same margin and same total duration either way. Do not split this wait across
+// both steps: that doubles the opening delay and guarantees nothing more.
//
-// Calibrage matériel, échelle descendante testée sur Codex Micro : 40 ms tient,
-// 20 ms échoue. La valeur retenue double ce plancher mesuré. Le délai
-// d'ouverture est donc de 80 ms, auquel s'ajoutent les 10 ms de retour visuel
-// ci-dessous, contre 900 ms pour la première version de la macro.
+// Hardware calibration, descending scale tested on the Codex Micro: 40ms holds,
+// 20ms fails. The chosen value doubles that measured floor. The opening delay is
+// therefore 80ms, plus the 10ms of visual feedback below, against 900ms for the
+// first version of the macro.
//
-// En descendant plus bas, l'échec n'est pas bruyant : la flèche part avant que
-// le sélecteur ait le focus et le changement de niveau est perdu sans trace.
-// Toute nouvelle baisse doit donc être validée par plusieurs répétitions ET par
-// une première ouverture à froid, au retour d'une autre application.
+// Going lower fails quietly: the arrow leaves before the picker has focus and
+// the level change is lost without a trace. Any further reduction must be
+// validated over several repetitions AND on a cold first open, coming back from
+// another application.
const EFFORT_PICKER_DELAY_MS = 80;
-// Attente portée par l'étape Escape, en millisecondes. Elle sert au retour
-// visuel, pas à la fiabilité, et ne doit pas être ramenée à 0.
+// Wait carried by the Escape step, in milliseconds. It serves visual feedback,
+// not reliability, and must not be dropped to 0.
//
-// Sans elle, la flèche et Escape sont émis sans écart et Claude les traite dans
-// le même tour de boucle : le sélecteur s'ouvre et se referme sans jamais peindre
-// une image montrant le slider à son nouveau niveau. On change donc l'effort à
-// l'aveugle, et l'effet visible est un simple clignotement. 10 ms suffisent à
-// laisser passer une image, et le niveau atteint devient lisible.
+// Without it, the arrow and Escape are emitted with no gap and Claude handles
+// them in the same loop turn: the picker opens and closes without ever painting
+// a frame showing the slider at its new level. Effort then changes blind, and
+// the visible effect is a mere flicker. 10ms is enough to let one frame through,
+// and the level reached becomes readable.
//
-// Cette attente est payée APRÈS que le niveau a changé : elle allonge la macro
-// sans retarder son effet. C'est aussi ce qui indique que `delay` s'applique
-// avant son étape et non après — dans la lecture « après » ces 10 ms seraient du
-// temps mort en fin de macro et ne changeraient rien à l'affichage.
+// This wait is paid AFTER the level has changed: it lengthens the macro without
+// delaying its effect. That is also what indicates `delay` applies before its
+// step and not after — under the "after" reading these 10ms would be dead time
+// at the end of the macro and would change nothing on screen.
const EFFORT_FEEDBACK_DELAY_MS = 10;
const WHEEL_MODES = {
@@ -389,17 +388,17 @@ function buildKeyInputs(keys) {
];
}
-// Claude Desktop ouvre le sélecteur d'effort avec ⌘⇧E. Son curseur ARIA
-// accepte ensuite gauche/droite pour passer au niveau disponible précédent ou
-// suivant. ⌘⇧E est une bascule vérifiée sur Claude Desktop : chaque cran doit
-// donc refermer le sélecteur avec Escape, sinon le cran suivant le referme au
-// lieu de l'ouvrir et le niveau est sauté.
+// Claude Desktop opens the effort picker with ⌘⇧E. Its ARIA slider then takes
+// left/right to move to the previous or next available level. ⌘⇧E is a toggle,
+// verified on Claude Desktop: every notch must therefore close the picker with
+// Escape, otherwise the next notch closes it instead of opening it and the level
+// is skipped.
//
-// Le sélecteur est rendu de façon asynchrone, il faut donc l'attendre avant
-// d'envoyer la flèche : EFFORT_PICKER_DELAY_MS, porté par la seule libération de
-// ⌘. Puis il faut le laisser peindre le niveau atteint avant de le refermer :
-// EFFORT_FEEDBACK_DELAY_MS, porté par Escape. L'étape de la flèche reste à 0,
-// c'est elle qui rend les deux lectures possibles de `delay` équivalentes.
+// The picker renders asynchronously, so it has to be waited for before sending
+// the arrow: EFFORT_PICKER_DELAY_MS, carried by the ⌘ release alone. Then it has
+// to be allowed to paint the level reached before closing:
+// EFFORT_FEEDBACK_DELAY_MS, carried by Escape. The arrow step stays at 0, and
+// that is what makes both possible readings of `delay` equivalent.
function buildEffortWheelKeyInputs(directionKeycode) {
return [
{ keycode: "KC_LGUI", delay: 0, actionType: 1 },
@@ -435,6 +434,29 @@ function decodeKeyInputs(keyInputs) {
return keys;
}
+let actionDecodingIndexes;
+
+function getActionDecodingIndexes() {
+ if (actionDecodingIndexes) return actionDecodingIndexes;
+
+ const directActionByKeycode = new Map();
+ const sequencedActionByInputs = new Map();
+ for (const [id, definition] of Object.entries(ACTION_DEFINITIONS)) {
+ if (definition.type === "direct") {
+ directActionByKeycode.set(FINAL_KEYCODES[definition.key], id);
+ } else if (definition.type === "directKeycode") {
+ directActionByKeycode.set(definition.keycode, id);
+ } else if (definition.type === "shortcut") {
+ sequencedActionByInputs.set(JSON.stringify(buildKeyInputs(definition.keys)), id);
+ } else if (definition.type === "sequence") {
+ sequencedActionByInputs.set(JSON.stringify(definition.keyInputs), id);
+ }
+ }
+
+ actionDecodingIndexes = { directActionByKeycode, sequencedActionByInputs };
+ return actionDecodingIndexes;
+}
+
function nextId(items) {
return items.reduce((highest, item) => Math.max(highest, Number(item.id) || 0), -1) + 1;
}
@@ -444,12 +466,7 @@ function findOrCreateAction(profile, name, keyInputs, createdActionIds) {
if (existing) return existing.id;
const id = nextId(profile.actions);
- profile.actions.push({
- id,
- name,
- color: null,
- keyInputs,
- });
+ profile.actions.push({ id, name, color: null, keyInputs });
createdActionIds.push(id);
return id;
}
@@ -479,12 +496,7 @@ function resolveKeyAssignment(profile, assignment, createdActionIds) {
}
if (definition.type === "sequence") {
const keyInputs = clone(definition.keyInputs);
- const id = findOrCreateAction(
- profile,
- definition.name,
- keyInputs,
- createdActionIds,
- );
+ const id = findOrCreateAction(profile, definition.name, keyInputs, createdActionIds);
return `KA_${id}`;
}
if (definition.type === "direct") {
@@ -533,21 +545,22 @@ function addActionsToGroup(profile, actionIds) {
group.actionIds = [...new Set([...group.actionIds, ...actionIds])];
}
-// Le joystick accepte, en plus des deux préréglages `navigation` et `none`, une
-// affectation par direction :
+// On top of the two `navigation` and `none` presets, the joystick accepts a
+// per-direction assignment:
//
// { directions: 4, sectors: ["newSession", "voice", "diff", "stop"] }
//
-// Chaque secteur prend la même valeur qu'une touche — identifiant du catalogue,
-// raccourci personnalisé, ou `none`. La sérialisation d'Input convertit bien les
-// références `KA_` dans les secteurs, donc une macro complète y est possible et
-// pas seulement un keycode nu.
+// Each sector takes the same value as a key — catalogue id, custom shortcut, or
+// `none`. Input's serialisation does convert `KA_` references inside sectors, so
+// a full macro is possible there and not only a bare keycode.
//
-// 45° restent réservés à la zone de fermeture `KI_X` en haut, exactement comme
-// le gabarit par défaut d'Input. Les 315° restants se partagent, soit 78,75° à
-// quatre directions et 39,4° à huit. Au-delà de huit, viser au pouce devient
-// hasardeux : la borne est ergonomique, le format n'en impose aucune.
+// 45° stay reserved for the `KI_X` close zone at the top, exactly like Input's
+// default template. The remaining 315° are shared out, so 78.75° at four
+// directions and 39.4° at eight. Beyond eight, aiming with a thumb gets
+// unreliable: the bound is ergonomic, the format imposes none.
const JOYSTICK_DIRECTION_COUNTS = Object.freeze([4, 8]);
+const JOYSTICK_CLOSE_ANGLE = 45 / 360;
+const JOYSTICK_START_ANGLE = (90 - 45 / 2) / 360;
function isCustomJoystick(value) {
return Boolean(value) && typeof value === "object" && Array.isArray(value.sectors);
@@ -566,23 +579,35 @@ function validateCustomJoystick(joystick) {
);
}
-function radialSectors(keycodes) {
- const closeAngle = 45 / 360;
- const start = (90 - 45 / 2) / 360;
- const remainingAngle = 1 - closeAngle;
- const sectorAngle = remainingAngle / keycodes.length;
- const sectors = [{ k: "KI_X", a1: start, a2: (start + closeAngle) % 1 }];
-
- keycodes.forEach((keycode, index) => {
- const a1 = start + closeAngle + sectorAngle * index;
- sectors.push({
- k: keycode,
- a1: a1 % 1,
- a2: (a1 + sectorAngle) % 1,
- });
- });
+function radialSectorGeometry(directionCount) {
+ assert(
+ Number.isInteger(directionCount) && directionCount > 0,
+ `Le joystick doit contenir au moins une direction, reçu : ${JSON.stringify(directionCount)}`,
+ "JOYSTICK_SECTOR_COUNT",
+ );
+ const sectorAngle = (1 - JOYSTICK_CLOSE_ANGLE) / directionCount;
+ return {
+ close: {
+ a1: JOYSTICK_START_ANGLE,
+ a2: (JOYSTICK_START_ANGLE + JOYSTICK_CLOSE_ANGLE) % 1,
+ },
+ sectors: Array.from({ length: directionCount }, (_, index) => {
+ const a1 = JOYSTICK_START_ANGLE + JOYSTICK_CLOSE_ANGLE + sectorAngle * index;
+ return { index, a1: a1 % 1, a2: (a1 + sectorAngle) % 1 };
+ }),
+ };
+}
- return sectors;
+function radialSectors(keycodes) {
+ const geometry = radialSectorGeometry(keycodes.length);
+ return [
+ { k: "KI_X", ...geometry.close },
+ ...geometry.sectors.map(({ a1, a2 }, index) => ({
+ k: keycodes[index],
+ a1,
+ a2,
+ })),
+ ];
}
export function inspectInputProfile(source, { requireAppSense = true } = {}) {
@@ -688,10 +713,10 @@ function hasClaudeLayout(layer) {
);
}
-// Crée le layer « Claude » à partir d'un export qui n'en contient pas, en
-// clonant la structure d'un layer existant. Le lien AppSense (linkedAppId)
-// référence le registre local d'Input et ne peut pas être inventé ici : le
-// layer créé doit être lié via « Auto detect » après import.
+// Creates the "Claude" layer from an export that has none, by cloning the
+// structure of an existing layer. The AppSense link (linkedAppId) references
+// Input's local registry and cannot be invented here: the created layer has to
+// be linked through "Auto detect" after import.
export function addClaudeLayer(source) {
assert(source && typeof source === "object", "Le fichier JSON est vide.", "EMPTY_FILE");
assert(
@@ -737,9 +762,9 @@ export function addClaudeLayer(source) {
layer.id = nextId(output.profile.layers);
layer.name = TARGET_LAYER_NAME;
delete layer.linkedAppId;
- // Sécurité par défaut : les contrôles assignables hérités du modèle sont
- // neutralisés. Le capteur base[3][0] conserve sa fonction de changement de
- // layer et ne sera jamais exposé dans le configurateur.
+ // Safe by default: the assignable controls inherited from the template are
+ // cleared. The base[3][0] sensor keeps its layer-change function and is never
+ // exposed in the configurator.
for (const [rowIndex, row] of layer.layout.base.entries()) {
if (!Array.isArray(row)) continue;
for (const [columnIndex, cell] of row.entries()) {
@@ -767,33 +792,20 @@ export function deriveMappingFromProfile(source) {
const inspection = inspectInputProfile(source, { requireAppSense: false });
const layer = source.profile.layers[inspection.layerIndex];
const actionsById = new Map(source.actions.map((action) => [String(action.id), action]));
+ const { directActionByKeycode, sequencedActionByInputs } = getActionDecodingIndexes();
const decodeCell = (cell) => {
const keycode = cell?.keycode;
if (!keycode || keycode === "KC_NONE") return "none";
- for (const [id, definition] of Object.entries(ACTION_DEFINITIONS)) {
- if (
- (definition.type === "direct" && FINAL_KEYCODES[definition.key] === keycode) ||
- (definition.type === "directKeycode" && definition.keycode === keycode)
- ) {
- return id;
- }
- }
+ const directAction = directActionByKeycode.get(keycode);
+ if (directAction) return directAction;
const reference = /^KA_(\d+)$/.exec(keycode);
if (reference) {
const action = actionsById.get(reference[1]);
if (!action) return "none";
- for (const [id, definition] of Object.entries(ACTION_DEFINITIONS)) {
- if (
- (definition.type === "shortcut" &&
- sameJson(action.keyInputs, buildKeyInputs(definition.keys))) ||
- (definition.type === "sequence" &&
- sameJson(action.keyInputs, definition.keyInputs))
- ) {
- return id;
- }
- }
+ const sequencedAction = sequencedActionByInputs.get(JSON.stringify(action.keyInputs));
+ if (sequencedAction) return sequencedAction;
const keys = decodeKeyInputs(action.keyInputs);
return keys ? { type: "custom", keys } : "none";
}
@@ -839,8 +851,8 @@ export function deriveMappingFromProfile(source) {
encoder[PHYSICAL_ENCODER_SLOTS.press],
);
- // Le premier secteur est toujours la zone de fermeture `KI_X` : les
- // directions utiles sont les suivants, dans l'ordre où radialSectors les pose.
+ // The first sector is always the `KI_X` close zone: the useful directions are
+ // the ones after it, in the order radialSectors lays them down.
const sectors = (layer.layout.joystick?.sectors ?? []).filter(
(sector) => sector.k !== "KI_X",
);
@@ -861,12 +873,11 @@ export function deriveMappingFromProfile(source) {
return { mapping, assigned };
}
-// Un fichier `*-profile.json` exporté par Input ne transporte PAS la table
-// `linkedApps`, seulement les références `linkedAppId` posées sur les layers.
-// Aucune des deux options ci-dessous ne peut donc créer une entrée : elles
-// écrivent une référence vers une entrée qui doit déjà exister sur la carte,
-// créée une fois dans l'UI d'Input. Une référence vers une entrée absente
-// s'importe sans erreur et laisse AppSense mort sans le dire.
+// A `*-profile.json` exported by Input does NOT carry the `linkedApps` table,
+// only the `linkedAppId` references set on the layers. Neither option below can
+// therefore create an entry: they write a reference to an entry that must
+// already exist on the board, created once in Input's UI. A reference to a
+// missing entry imports without error and leaves AppSense dead without saying so.
function validateAppSenseId(value, label) {
assert(
Number.isInteger(value) && value >= 0,
@@ -886,8 +897,8 @@ export function buildInputProfile(
if (forcesClaudeLink) validateAppSenseId(appSenseId, "appSenseId");
if (linksBaseLayer) validateAppSenseId(baseLayerAppSenseId, "baseLayerAppSenseId");
- // Deux layers liés à la même entrée rendent la bascule ambiguë : le firmware
- // ne documente pas dans quel ordre il parcourt sa table.
+ // Two layers linked to the same entry make the switch ambiguous: the firmware
+ // does not document the order in which it walks its table.
if (forcesClaudeLink && linksBaseLayer) {
assert(
appSenseId !== baseLayerAppSenseId,
@@ -896,8 +907,8 @@ export function buildInputProfile(
);
}
- // Forcer le lien du layer Claude rend son absence dans la source acceptable :
- // c'est précisément le cas d'usage, réparer un lien perdu.
+ // Forcing the Claude layer's link makes its absence in the source acceptable:
+ // that is precisely the use case, repairing a lost link.
const inspection = inspectInputProfile(source, {
requireAppSense: requireAppSense && !forcesClaudeLink,
});
@@ -921,9 +932,10 @@ export function buildInputProfile(
);
}
- const wheelMode = WHEEL_MODES[mapping.wheel];
+ const hasWheelMode = Object.hasOwn(WHEEL_MODES, mapping.wheel);
+ const wheelMode = hasWheelMode ? WHEEL_MODES[mapping.wheel] : null;
assert(
- mapping.wheel in WHEEL_MODES,
+ hasWheelMode,
`Action inconnue pour la molette : ${mapping.wheel}`,
"UNKNOWN_ASSIGNMENT",
);
@@ -982,9 +994,9 @@ export function buildInputProfile(
addActionsToGroup(output, createdActionIds);
if (forcesClaudeLink) targetLayer.linkedAppId = appSenseId;
- // AppSense n'a pas de retour : chaque règle est une transition aller. Lier le
- // layer natif à une seconde application est le seul moyen de quitter le layer
- // Claude automatiquement, en entrant dans ce layer-là. Voir
+ // AppSense has no return path: every rule is a one-way transition. Linking the
+ // native layer to a second application is the only way to leave the Claude
+ // layer automatically, by entering that one. See
// docs/research/appsense-behavior.md.
if (linksBaseLayer) output.profile.layers[0].linkedAppId = baseLayerAppSenseId;
@@ -1004,8 +1016,8 @@ export function buildInputProfile(
"Le lien AppSense du layer Claude n’a pas été préservé.",
);
if (linksBaseLayer) {
- // Le lien change, jamais le keymap : les touches natives OpenAI doivent
- // rester intactes au keycode près.
+ // The link changes, never the keymap: the native OpenAI keys must stay
+ // intact down to the keycode.
assert(
sameJson(source.profile.layers[0].layout, output.profile.layers[0].layout),
"Le keymap du layer natif Work Louder a été modifié.",
@@ -1033,12 +1045,12 @@ export function buildInputProfile(
preservedLayers: output.profile.layers.length - 1,
nativeLayerPreserved: true,
appSensePreserved: inspection.appSenseLinked,
- // Référence effectivement écrite sur le layer Claude, forcée ou héritée.
+ // Reference actually written on the Claude layer, forced or inherited.
appSenseId: forcesClaudeLink
? appSenseId
: output.profile.layers[inspection.layerIndex].linkedAppId,
appSenseForced: forcesClaudeLink,
- // Lien du layer natif, qui fournit la transition de sortie du layer Claude.
+ // Native layer link, which provides the exit transition out of the Claude layer.
baseLayerAppSenseId: linksBaseLayer ? baseLayerAppSenseId : null,
assignedSwitches: [...KEY_CONTROL_ORDER, ENCODER_PRESS_CONTROL].filter(
(controlId) => mapping[controlId] !== "none",
@@ -1061,7 +1073,9 @@ export {
FORBIDDEN_KEYS,
KEY_CONTROL_LOCATIONS,
KEY_CONTROL_ORDER,
+ JOYSTICK_DIRECTION_COUNTS,
MODIFIER_KEYCODES,
PRINTABLE_KEYS,
WHEEL_MODES,
+ radialSectorGeometry,
};
diff --git a/shared/thread-status-palette.mjs b/shared/thread-status-palette.mjs
index 86c1720..fdc45bd 100644
--- a/shared/thread-status-palette.mjs
+++ b/shared/thread-status-palette.mjs
@@ -1,11 +1,11 @@
-// Palette des états de session Claude Code, partagée entre l'outillage Node et le
-// GUI. Elle vit dans `shared/` parce que c'est la seule chose que les deux côtés
-// ont besoin de connaître en commun : le réducteur reste dans `scripts/lib/`, où
-// le GUI n'a rien à aller chercher.
+// Claude Code session state palette, shared between the Node tooling and the
+// GUI. It lives in `shared/` because it is the only thing both sides need to
+// know in common: the reducer stays in `scripts/lib/`, where the GUI has nothing
+// to look for.
//
-// Source unique de vérité. `scripts/lib/thread-slots.mjs` la réexporte pour ne
-// pas casser ses importateurs, et le GUI l'importe pour afficher sa légende — les
-// deux ne peuvent donc pas diverger.
+// Single source of truth. `scripts/lib/thread-slots.mjs` re-exports it so its
+// importers keep working, and the GUI imports it to draw its legend — so the two
+// cannot diverge.
export const STATES = Object.freeze({
free: "free",
@@ -16,9 +16,9 @@ export const STATES = Object.freeze({
ended: "ended",
});
-// Teintes reprises de la palette du dépôt. `blocked` est la seule ajoutée :
-// aucune couleur existante ne signifiait « une décision est attendue ».
-// `free` vaut `null` : un emplacement libre est éteint, pas coloré.
+// Hues taken from the repository palette. `blocked` is the only one added: no
+// existing colour meant "a decision is waiting".
+// `free` is `null`: a free slot is unlit, not coloured.
export const STATE_COLORS = Object.freeze({
free: null,
idle: "#6D5A7D",
@@ -28,8 +28,8 @@ export const STATE_COLORS = Object.freeze({
ended: "#2F2927",
});
-// Ordre de lecture pour une légende : du plus urgent au plus inerte. Ce n'est pas
-// l'ordre de `STATES`, qui suit le cycle de vie d'une session.
+// Reading order for a legend: from the most urgent to the most inert. This is
+// not the order of `STATES`, which follows a session's life cycle.
export const LEGEND_ORDER = Object.freeze([
STATES.blocked,
STATES.running,
diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs
new file mode 100644
index 0000000..cb0bb0c
--- /dev/null
+++ b/tests/cli.test.mjs
@@ -0,0 +1,214 @@
+import assert from "node:assert/strict";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { sourceProfile } from "./helpers/input-profile-fixture.mjs";
+
+const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+function runScript(relativePath, args = [], options = {}) {
+ return spawnSync(process.execPath, [path.join(repositoryRoot, relativePath), ...args], {
+ cwd: repositoryRoot,
+ encoding: "utf8",
+ timeout: 5000,
+ ...options,
+ });
+}
+
+async function temporaryDirectory(t) {
+ const directory = await mkdtemp(path.join(os.tmpdir(), "codex-micro-test-"));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ return directory;
+}
+
+test("every command exposes a safe help path without requiring hardware", async (t) => {
+ const cases = [
+ ["scripts/input-layer.mjs", ["help"]],
+ ["scripts/lighting.mjs", ["--help"]],
+ ["scripts/lighting-probe.mjs", ["--help"]],
+ ["scripts/thread-status.mjs", ["--help"]],
+ ["scripts/enable-agent-keys.mjs", []],
+ ["scripts/configure.mjs", ["--help"]],
+ ];
+
+ for (const [script, args] of cases) {
+ await t.test(script, () => {
+ const result = runScript(script, args);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Usage:/);
+ assert.equal(result.stderr, "");
+ });
+ }
+
+ const missingArguments = runScript("scripts/build-input-profile.mjs");
+ assert.equal(missingArguments.status, 1);
+ assert.match(missingArguments.stderr, /Usage:/);
+});
+
+test("repository validation commands run successfully from the checkout", async (t) => {
+ for (const script of [
+ "scripts/validate-profile.mjs",
+ "scripts/validate-presets.mjs",
+ "scripts/check-doc-links.mjs",
+ "scripts/prepare-gui.mjs",
+ ]) {
+ await t.test(script, () => {
+ const result = runScript(script);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /OK:/);
+ });
+ }
+});
+
+test("build-input-profile writes a validated profile once and refuses overwrite", async (t) => {
+ const directory = await temporaryDirectory(t);
+ const inputPath = path.join(directory, "source-profile.json");
+ const outputPath = path.join(directory, "built-profile.json");
+ await writeFile(inputPath, JSON.stringify(sourceProfile()));
+
+ const result = runScript("scripts/build-input-profile.mjs", [
+ inputPath,
+ outputPath,
+ "--app-sense-id=12",
+ "--base-layer-app-sense-id=13",
+ ]);
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Input profile written/);
+
+ const output = JSON.parse(await readFile(outputPath, "utf8"));
+ assert.equal(output.profile.name, "Claude macOS");
+ assert.equal(output.profile.layers[0].linkedAppId, 13);
+ assert.equal(output.profile.layers[1].linkedAppId, 12);
+
+ const before = await readFile(outputPath, "utf8");
+ const overwrite = runScript("scripts/build-input-profile.mjs", [inputPath, outputPath]);
+ assert.notEqual(overwrite.status, 0);
+ assert.equal(await readFile(outputPath, "utf8"), before);
+});
+
+test("input-layer inspects, inventories, and plans from an isolated official export", async (t) => {
+ const directory = await temporaryDirectory(t);
+ const profilePath = path.join(directory, "source-profile.json");
+ const inventoryPath = path.join(directory, "inventory.json");
+ await writeFile(profilePath, JSON.stringify(sourceProfile()));
+
+ const inspection = runScript("scripts/input-layer.mjs", [
+ "inspect-export",
+ "--input",
+ profilePath,
+ "--json",
+ ]);
+ assert.equal(inspection.status, 0, inspection.stderr);
+ const inspectionResult = JSON.parse(inspection.stdout);
+ assert.equal(inspectionResult.kind, "profile");
+ assert.equal("payload" in inspectionResult, false, "the CLI summary must not echo the profile");
+
+ const inventory = runScript("scripts/input-layer.mjs", [
+ "inventory",
+ "--profile-export",
+ profilePath,
+ "--output",
+ inventoryPath,
+ "--json",
+ ]);
+ assert.equal(inventory.status, 0, inventory.stderr);
+ const inventoryResult = JSON.parse(inventory.stdout);
+ assert.equal(inventoryResult.inventory.layers[1].name, "Claude");
+ assert.equal(inventoryResult.inventory.layers[1].appSenseLinked, true);
+
+ const plan = runScript("scripts/input-layer.mjs", [
+ "install",
+ "--inventory",
+ inventoryPath,
+ "--json",
+ ]);
+ assert.equal(plan.status, 0, plan.stderr);
+ const planResult = JSON.parse(plan.stdout);
+ assert.equal(planResult.dryRun, true);
+ assert.equal(planResult.plan.targetLayerIndex, 1);
+ assert.equal(planResult.plan.canGenerateProfile, true);
+ assert.deepEqual(planResult.plan.blockers, []);
+});
+
+test("enable-agent-keys changes only the six Claude cells and can revert them", async (t) => {
+ const directory = await temporaryDirectory(t);
+ const sourcePath = path.join(directory, "source-profile.json");
+ const enabledPath = path.join(directory, "enabled-profile.json");
+ const revertedPath = path.join(directory, "reverted-profile.json");
+ const source = sourceProfile();
+ const nativeLayer = structuredClone(source.profile.layers[0]);
+ await writeFile(sourcePath, JSON.stringify(source));
+
+ const enabled = runScript("scripts/enable-agent-keys.mjs", [sourcePath, enabledPath]);
+ assert.equal(enabled.status, 0, enabled.stderr);
+ const enabledProfile = JSON.parse(await readFile(enabledPath, "utf8"));
+ assert.deepEqual(enabledProfile.profile.layers[0], nativeLayer);
+ assert.deepEqual(
+ enabledProfile.profile.layers[1].layout.base.slice(0, 2).map((row) =>
+ row.map((cell) => cell.keycode),
+ ),
+ [
+ ["KV_OAI_AG00", "KV_OAI_AG01"],
+ ["KV_OAI_AG02", "KV_OAI_AG03", "KV_OAI_AG04", "KV_OAI_AG05"],
+ ],
+ );
+
+ const reverted = runScript("scripts/enable-agent-keys.mjs", [
+ enabledPath,
+ revertedPath,
+ "--revert",
+ ]);
+ assert.equal(reverted.status, 0, reverted.stderr);
+ const revertedProfile = JSON.parse(await readFile(revertedPath, "utf8"));
+ assert.equal(
+ revertedProfile.profile.layers[1].layout.base
+ .slice(0, 2)
+ .flat()
+ .every((cell) => cell.keycode === "KC_NONE"),
+ true,
+ );
+});
+
+test("the thread-status hook records only transition metadata and stays silent", async (t) => {
+ const directory = await temporaryDirectory(t);
+ const env = {
+ ...process.env,
+ CLAUDE_THREAD_STATUS_DIR: directory,
+ CLAUDE_CODE_SESSION_ID: "session-from-env",
+ CLAUDE_PID: "1234",
+ CLAUDE_CODE_ENTRYPOINT: "cli",
+ };
+ const payload = {
+ hook_event_name: "Notification",
+ notification_type: "permission_prompt",
+ cwd: "/tmp/project",
+ message: "must never be persisted",
+ };
+
+ const emitted = runScript("thread-status/bin/emit.mjs", [], {
+ env,
+ input: JSON.stringify(payload),
+ });
+ assert.equal(emitted.status, 0);
+ assert.equal(emitted.stdout, "");
+ assert.equal(emitted.stderr, "");
+
+ const journalPath = path.join(directory, "events.ndjson");
+ const record = JSON.parse((await readFile(journalPath, "utf8")).trim());
+ assert.equal(record.event, "Notification");
+ assert.equal(record.sessionId, "session-from-env");
+ assert.equal(record.notificationType, "permission_prompt");
+ assert.equal(record.pid, 1234);
+ assert.equal("message" in record, false);
+
+ const before = await readFile(journalPath, "utf8");
+ const invalid = runScript("thread-status/bin/emit.mjs", [], {
+ env,
+ input: "not-json",
+ });
+ assert.equal(invalid.status, 0);
+ assert.equal(await readFile(journalPath, "utf8"), before);
+});
diff --git a/tests/gui-dependencies.test.mjs b/tests/gui-dependencies.test.mjs
new file mode 100644
index 0000000..8e853b2
--- /dev/null
+++ b/tests/gui-dependencies.test.mjs
@@ -0,0 +1,95 @@
+import assert from "node:assert/strict";
+import path from "node:path";
+import test from "node:test";
+import { ensureGuiDependencies } from "../scripts/lib/gui-dependencies.mjs";
+
+const guiDirectory = path.resolve("/tmp", "codex-micro-prototype");
+
+function dependencyPaths(platform = "linux") {
+ return {
+ packageJson: path.join(guiDirectory, "package.json"),
+ vite: path.join(
+ guiDirectory,
+ "node_modules",
+ ".bin",
+ platform === "win32" ? "vite.cmd" : "vite",
+ ),
+ };
+}
+
+test("skips installation when the locked GUI dependencies are already present", () => {
+ const { packageJson, vite } = dependencyPaths();
+ let spawned = false;
+ const result = ensureGuiDependencies(guiDirectory, {
+ exists: (candidate) => candidate === packageJson || candidate === vite,
+ spawn: () => {
+ spawned = true;
+ },
+ log: () => assert.fail("an existing install should not log preparation"),
+ platform: "linux",
+ });
+
+ assert.deepEqual(result, { installed: false });
+ assert.equal(spawned, false);
+});
+
+test("installs with the platform-specific npm command and locked options", () => {
+ const { packageJson } = dependencyPaths("win32");
+ const calls = [];
+ const logs = [];
+ const result = ensureGuiDependencies(guiDirectory, {
+ exists: (candidate) => candidate === packageJson,
+ spawn: (...args) => {
+ calls.push(args);
+ return { status: 0 };
+ },
+ log: (message) => logs.push(message),
+ platform: "win32",
+ });
+
+ assert.deepEqual(result, { installed: true });
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0][0], "npm.cmd");
+ assert.deepEqual(calls[0][1], ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]);
+ assert.deepEqual(calls[0][2], { cwd: guiDirectory, stdio: "inherit" });
+ assert.equal(logs.length, 1);
+});
+
+test("reports missing files, spawn failures, and non-zero install exits", () => {
+ assert.throws(
+ () => ensureGuiDependencies(guiDirectory, { exists: () => false }),
+ /prototype\/package\.json is missing/,
+ );
+
+ const { packageJson } = dependencyPaths();
+ const exists = (candidate) => candidate === packageJson;
+ assert.throws(
+ () =>
+ ensureGuiDependencies(guiDirectory, {
+ exists,
+ spawn: () => ({ error: new Error("spawn failed") }),
+ log: () => {},
+ }),
+ /spawn failed/,
+ );
+
+ assert.throws(
+ () =>
+ ensureGuiDependencies(guiDirectory, {
+ exists,
+ spawn: () => ({ status: 17 }),
+ log: () => {},
+ }),
+ (error) => error.exitCode === 17 && /code 17/.test(error.message),
+ );
+
+ assert.throws(
+ () =>
+ ensureGuiDependencies(guiDirectory, {
+ exists,
+ spawn: () => ({ status: null }),
+ log: () => {},
+ }),
+ (error) => error.exitCode === 1 && /code 1/.test(error.message),
+ );
+});
diff --git a/tests/helpers/input-profile-fixture.mjs b/tests/helpers/input-profile-fixture.mjs
new file mode 100644
index 0000000..5e6a47c
--- /dev/null
+++ b/tests/helpers/input-profile-fixture.mjs
@@ -0,0 +1,81 @@
+export function sourceProfile() {
+ return {
+ keyboard: "codex_micro",
+ language: "us",
+ profile: {
+ id: 0,
+ name: "Default",
+ layers: [
+ {
+ id: 0,
+ name: "Layer 1",
+ layout: {
+ encoders: [[
+ { keycode: "KV_OAI_ENC_CC" },
+ { keycode: "KV_OAI_ENC_CW" },
+ { keycode: "KV_OAI_ENC_CLK" },
+ ]],
+ joystick: { type: "VENDOR", sectors: [] },
+ base: [[{ keycode: "KV_0" }]],
+ },
+ },
+ {
+ id: 1,
+ name: "Claude",
+ linkedAppId: 7,
+ layout: {
+ encoders: [[
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ ]],
+ joystick: {
+ type: "RADIAL",
+ sectors: [
+ { k: "KI_X", a1: 0.1875, a2: 0.3125 },
+ { k: "KC_NONE", a1: 0.3125, a2: 0.1875 },
+ ],
+ },
+ base: [
+ [{ keycode: "KC_NONE" }, { keycode: "KC_NONE" }],
+ [
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ ],
+ [
+ { keycode: "KA_0" },
+ { keycode: "KA_1" },
+ { keycode: "KA_2" },
+ { keycode: "KC_ESC" },
+ ],
+ [
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ { keycode: "KC_NONE" },
+ ],
+ ],
+ },
+ },
+ ],
+ },
+ actions: [
+ {
+ id: 0,
+ name: "Claude New",
+ color: null,
+ keyInputs: [
+ { keycode: "KC_LGUI", delay: 0, actionType: 1 },
+ { keycode: "KC_N", delay: 0, actionType: 2 },
+ { keycode: "KC_LGUI", delay: 0, actionType: 0 },
+ ],
+ },
+ ],
+ multiactions: [],
+ smartActions: [],
+ actionGroups: [{ id: 0, name: "Default", actionIds: [0] }],
+ multiactionGroups: [],
+ smartActionGroups: [],
+ };
+}
diff --git a/tests/hid-device.test.mjs b/tests/hid-device.test.mjs
new file mode 100644
index 0000000..6a627d3
--- /dev/null
+++ b/tests/hid-device.test.mjs
@@ -0,0 +1,173 @@
+import assert from "node:assert/strict";
+import { EventEmitter } from "node:events";
+import test from "node:test";
+import {
+ DeviceSession,
+ PRODUCT_ID,
+ VENDOR_ID,
+ VENDOR_USAGE_PAGE,
+ isCodexVendorInterface,
+} from "../scripts/lib/hid-device.mjs";
+import {
+ CHANNEL_DEBUG,
+ REPORT_ID,
+ REPORT_SIZE,
+ decodeReport,
+ encodeFrames,
+} from "../scripts/lib/hid-frame.mjs";
+import { METHODS } from "../scripts/lib/hid-lighting.mjs";
+
+class FakeHandle extends EventEmitter {
+ writes = [];
+ writeError = null;
+ closed = false;
+
+ async write(frame) {
+ if (this.writeError) throw this.writeError;
+ this.writes.push(frame);
+ }
+
+ async close() {
+ this.closed = true;
+ this.emit("close");
+ }
+}
+
+function emitRpc(handle, payload) {
+ for (const frame of encodeFrames(`${JSON.stringify(payload)}\n`)) {
+ handle.emit("data", frame);
+ }
+}
+
+function emitDebug(handle, line) {
+ const bytes = Buffer.from(`${line}\n`, "utf8");
+ const frame = Buffer.alloc(REPORT_SIZE);
+ frame[0] = REPORT_ID;
+ frame[1] = CHANNEL_DEBUG;
+ frame[2] = bytes.length;
+ bytes.copy(frame, 3);
+ handle.emit("data", frame);
+}
+
+async function waitFor(predicate, message = "condition") {
+ const deadline = Date.now() + 500;
+ while (!predicate()) {
+ if (Date.now() >= deadline) assert.fail(`Timed out waiting for ${message}`);
+ await new Promise((resolve) => setTimeout(resolve, 1));
+ }
+}
+
+test("filters the vendor RPC collection without touching unrelated HID interfaces", () => {
+ const match = {
+ vendorId: VENDOR_ID,
+ productId: PRODUCT_ID,
+ usagePage: VENDOR_USAGE_PAGE,
+ };
+ assert.equal(isCodexVendorInterface(match), true);
+ assert.equal(isCodexVendorInterface({ ...match, usagePage: 1 }), false);
+ assert.equal(isCodexVendorInterface({ ...match, productId: 1 }), false);
+ assert.equal(isCodexVendorInterface(null), false);
+});
+
+test("writes an RPC request and resolves the matching response", async () => {
+ const handle = new FakeHandle();
+ const session = new DeviceSession(handle);
+ const response = session.call("sys.version", null, 41);
+
+ await waitFor(() => handle.writes.length === 1, "the first HID write");
+ const request = JSON.parse(decodeReport(handle.writes[0]).payload);
+ assert.deepEqual(request, { method: "sys.version", params: null, id: 41 });
+
+ emitRpc(handle, { result: { version: "0.4.1" }, id: 41 });
+ assert.deepEqual(await response, { result: { version: "0.4.1" }, id: 41 });
+ await session.close();
+ assert.equal(handle.closed, true);
+});
+
+test("keeps exactly one request in flight and starts the next after its response", async () => {
+ const handle = new FakeHandle();
+ const session = new DeviceSession(handle);
+ const first = session.call("first", null, 1);
+ const second = session.call("second", null, 2);
+
+ await waitFor(() => handle.writes.length === 1, "the first queued request");
+ await new Promise((resolve) => setTimeout(resolve, 65));
+ assert.equal(handle.writes.length, 1, "the second request must wait for the first response");
+
+ emitRpc(handle, { result: "one", id: 1 });
+ assert.equal((await first).result, "one");
+ await waitFor(() => handle.writes.length === 2, "the second queued request");
+ emitRpc(handle, { result: "two", id: 2 });
+ assert.equal((await second).result, "two");
+ await session.close();
+});
+
+test("dispatches debug lines, notifications, and foreign lighting writes", async () => {
+ const handle = new FakeHandle();
+ const debugLines = [];
+ const foreignWrites = [];
+ const notifications = [];
+ const session = new DeviceSession(handle, {
+ onDebugLine: (line) => debugLines.push(line),
+ onForeignWrite: (method, message) => foreignWrites.push({ method, message }),
+ });
+
+ const oldHandler = () => notifications.push("old");
+ const removeOld = session.onNotification(METHODS.notifyHid, oldHandler);
+ const removeCurrent = session.onNotification(METHODS.notifyHid, (params) => {
+ notifications.push(params);
+ });
+ removeOld();
+
+ handle.emit("data", Buffer.alloc(2));
+ emitDebug(handle, "ready");
+ emitRpc(handle, { method: METHODS.notifyHid, params: { key: 3 } });
+ emitRpc(handle, { method: METHODS.threadsLighting, result: { ok: true }, id: 999 });
+
+ assert.deepEqual(debugLines, ["ready"]);
+ assert.deepEqual(notifications, [{ key: 3 }]);
+ assert.equal(foreignWrites.length, 1);
+ assert.equal(foreignWrites[0].method, METHODS.threadsLighting);
+
+ removeCurrent();
+ emitRpc(handle, { method: METHODS.notifyHid, params: { key: 4 } });
+ assert.deepEqual(notifications, [{ key: 3 }]);
+ await session.close();
+});
+
+test("turns RPC, write, and device failures into stable DeviceError codes", async () => {
+ const rpcHandle = new FakeHandle();
+ const rpcSession = new DeviceSession(rpcHandle);
+ const rpcFailure = assert.rejects(rpcSession.call("broken", null, 5), {
+ name: "DeviceError",
+ code: "RPC_ERROR",
+ });
+ await waitFor(() => rpcHandle.writes.length === 1, "the failing RPC request");
+ emitRpc(rpcHandle, { error: { message: "bad request" }, id: 5 });
+ await rpcFailure;
+ await rpcSession.close();
+
+ const writeHandle = new FakeHandle();
+ writeHandle.writeError = new Error("write refused");
+ const writeSession = new DeviceSession(writeHandle);
+ await assert.rejects(writeSession.call("write", null, 6), {
+ name: "DeviceError",
+ code: "WRITE_FAILED",
+ });
+ await writeSession.close();
+
+ const disconnectedHandle = new FakeHandle();
+ const disconnectedSession = new DeviceSession(disconnectedHandle);
+ const current = assert.rejects(disconnectedSession.call("current", null, 7), {
+ name: "DeviceError",
+ code: "DEVICE_ERROR",
+ });
+ const queued = assert.rejects(disconnectedSession.call("queued", null, 8), {
+ name: "DeviceError",
+ code: "DEVICE_ERROR",
+ });
+ await waitFor(() => disconnectedHandle.writes.length === 1, "the active request");
+ disconnectedHandle.emit("error", new Error("transport failed"));
+ await Promise.all([current, queued]);
+ await disconnectedSession.close();
+});
diff --git a/tests/hid-frame.test.mjs b/tests/hid-frame.test.mjs
index 37bc6ea..8b99cb4 100644
--- a/tests/hid-frame.test.mjs
+++ b/tests/hid-frame.test.mjs
@@ -15,7 +15,7 @@ import {
escapeUnicode,
} from "../scripts/lib/hid-frame.mjs";
-test("l'identifiant RPC reste dans la borne firmware [0, 999)", () => {
+test("the RPC id stays within the firmware bound [0, 999)", () => {
for (let index = 0; index < 200; index += 1) {
const id = createRpcId();
assert.ok(Number.isInteger(id));
@@ -23,7 +23,7 @@ test("l'identifiant RPC reste dans la borne firmware [0, 999)", () => {
}
});
-test("l'enveloppe de requête est {method, params, id}, params null par défaut", () => {
+test("the request envelope is {method, params, id}, params null by default", () => {
assert.equal(buildRequest({ method: "sys.version", id: 42 }), '{"method":"sys.version","params":null,"id":42}');
assert.equal(
buildRequest({ method: "v.oai.thstatus", params: [{ id: 0, c: 1 }], id: 7 }),
@@ -31,19 +31,19 @@ test("l'enveloppe de requête est {method, params, id}, params null par défaut"
);
});
-test("l'enveloppe valide la méthode et la borne de l'identifiant", () => {
- assert.throws(() => buildRequest({ method: "", id: 1 }), /méthode/);
- assert.throws(() => buildRequest({ method: "x", id: -1 }), /entre 0/);
- assert.throws(() => buildRequest({ method: "x", id: RPC_ID_LIMIT }), /entre 0/);
+test("the envelope validates the method and the id bound", () => {
+ assert.throws(() => buildRequest({ method: "", id: 1 }), /RPC method name/);
+ assert.throws(() => buildRequest({ method: "x", id: -1 }), /between 0/);
+ assert.throws(() => buildRequest({ method: "x", id: RPC_ID_LIMIT }), /between 0/);
});
-test("les caractères non ASCII sont échappés, y compris hors BMP", () => {
+test("non-ASCII characters are escaped, including beyond the BMP", () => {
assert.equal(escapeUnicode("aéb"), "a\\u00e9b");
assert.equal(escapeUnicode("🎹"), "\\ud83c\\udfb9");
assert.equal(escapeUnicode("ascii"), "ascii");
});
-test("un message court tient en un rapport de 64 octets avec en-tête 06/02/longueur", () => {
+test("a short message fits one 64-byte report with a 06/02/length header", () => {
const frames = encodeFrames('{"a":1}');
assert.equal(frames.length, 1);
const frame = frames[0];
@@ -55,31 +55,31 @@ test("un message court tient en un rapport de 64 octets avec en-tête 06/02/long
assert.equal(frame.subarray(10).every((byte) => byte === 0), true);
});
-test("la frontière de 61 octets découpe exactement", () => {
+test("the 61-byte boundary splits exactly", () => {
assert.equal(encodeFrames("x".repeat(CHUNK_PAYLOAD)).length, 1);
const frames = encodeFrames("x".repeat(CHUNK_PAYLOAD + 1));
assert.equal(frames.length, 2);
assert.equal(frames[0][2], CHUNK_PAYLOAD);
assert.equal(frames[1][2], 1);
- // Même en-tête sur les rapports de continuation, seule la longueur varie.
+ // Same header on the continuation reports, only the length varies.
assert.equal(frames[1][0], REPORT_ID);
assert.equal(frames[1][1], CHANNEL_RPC);
});
-test("un message vide n'émet aucun rapport", () => {
+test("an empty message emits no report", () => {
assert.equal(encodeFrames("").length, 0);
});
-test("un rapport se décode : canal, longueur, charge utile", () => {
+test("a report decodes into channel, length and payload", () => {
const [frame] = encodeFrames("bonjour");
const decoded = decodeReport(frame);
assert.equal(decoded.channel, CHANNEL_RPC);
assert.equal(decoded.length, 7);
assert.equal(decoded.payload, "bonjour");
- assert.throws(() => decodeReport(Buffer.alloc(2)), /trop court/);
+ assert.throws(() => decodeReport(Buffer.alloc(2)), /too short/);
});
-test("l'assembleur réunit les fragments et découpe aux sauts de ligne", () => {
+test("the assembler joins fragments and splits on newlines", () => {
const assemble = createLineAssembler();
const frames = encodeFrames('{"a":"' + "x".repeat(80) + '"}\n');
assert.ok(frames.length > 1);
@@ -90,7 +90,7 @@ test("l'assembleur réunit les fragments et découpe aux sauts de ligne", () =>
assert.equal(JSON.parse(lines[0].line).a, "x".repeat(80));
});
-test("l'assembleur gère \\r\\n, plusieurs lignes et la séparation des canaux", () => {
+test("the assembler handles \\r\\n, several lines and channel separation", () => {
const assemble = createLineAssembler();
const rpcFrame = Buffer.alloc(REPORT_SIZE);
rpcFrame[0] = REPORT_ID;
@@ -115,7 +115,7 @@ test("l'assembleur gère \\r\\n, plusieurs lignes et la séparation des canaux",
);
});
-test("l'accumulateur classe réponses, notifications et messages invalides", () => {
+test("the accumulator sorts responses, notifications and invalid messages", () => {
const accumulate = createRpcAccumulator();
const response = accumulate('{"result":{"ok":1},"id":475,"method":"v.oai.thstatus"}');
assert.equal(response.kind, "response");
@@ -131,7 +131,7 @@ test("l'accumulateur classe réponses, notifications et messages invalides", ()
assert.equal(invalid.kind, "invalid");
});
-test("l'accumulateur attend la fin d'un JSON fragmenté et saute les préfixes parasites", () => {
+test("the accumulator waits for the end of a fragmented JSON and skips stray prefixes", () => {
const accumulate = createRpcAccumulator();
assert.equal(accumulate('bruit sans json'), null);
assert.equal(accumulate('{"id":12,"res'), null);
@@ -139,7 +139,7 @@ test("l'accumulateur attend la fin d'un JSON fragmenté et saute les préfixes p
assert.equal(message.kind, "response");
assert.equal(message.id, "12");
- // Après un message complet, l'accumulateur repart à zéro.
+ // After a complete message, the accumulator starts over.
const next = accumulate('{"i":3,"result":2}');
assert.equal(next.kind, "response");
assert.equal(next.id, "3");
diff --git a/tests/hid-lighting.test.mjs b/tests/hid-lighting.test.mjs
index 2babbd8..b40a996 100644
--- a/tests/hid-lighting.test.mjs
+++ b/tests/hid-lighting.test.mjs
@@ -13,12 +13,12 @@ import {
} from "../scripts/lib/hid-lighting.mjs";
import { SLOT_CONTROLS, STATE_COLORS, STATES } from "../scripts/lib/thread-slots.mjs";
-test("la table thread provisoire suit l'ordre physique des six touches Agent", () => {
+test("the thread id table follows the physical order of the six Agent keys", () => {
assert.deepEqual([...SLOT_THREAD_IDS], [0, 1, 2, 3, 4, 5]);
assert.equal(SLOT_THREAD_IDS.length, SLOT_CONTROLS.length);
});
-test("les couleurs #RRGGBB deviennent des entiers RGB compactés", () => {
+test("#RRGGBB colours become packed RGB integers", () => {
assert.equal(colorToInt("#D97757"), 0xd97757);
assert.equal(colorToInt("d97757"), 0xd97757);
assert.equal(colorToInt(0x123456), 0x123456);
@@ -27,7 +27,7 @@ test("les couleurs #RRGGBB deviennent des entiers RGB compactés", () => {
assert.throws(() => colorToInt(0x1000000), /#RRGGBB/);
});
-test("une entrée thread est minimisée et n'exige que l'identifiant", () => {
+test("a thread entry is minified and requires only the id", () => {
assert.deepEqual(threadEntry({ id: 3 }), { id: 3 });
assert.deepEqual(
threadEntry({ id: 3, color: "#C2483D", brightness: 0.5, effect: EFFECTS.breath, speed: 1 }),
@@ -39,15 +39,15 @@ test("une entrée thread est minimisée et n'exige que l'identifiant", () => {
);
});
-test("les entrées thread valident leurs bornes", () => {
- assert.throws(() => threadEntry({}), /Identifiant/);
- assert.throws(() => threadEntry({ id: -1 }), /Identifiant/);
+test("thread entries validate their bounds", () => {
+ assert.throws(() => threadEntry({}), /thread id/);
+ assert.throws(() => threadEntry({ id: -1 }), /thread id/);
assert.throws(() => threadEntry({ id: 0, brightness: 1.5 }), /brightness/);
assert.throws(() => threadEntry({ id: 0, speed: -0.1 }), /speed/);
- assert.throws(() => threadEntry({ id: 0, effect: 99 }), /Effet/);
+ assert.throws(() => threadEntry({ id: 0, effect: 99 }), /Unknown effect/);
});
-test("threadsLightingParams compose un tableau d'entrées", () => {
+test("threadsLightingParams composes an array of entries", () => {
const params = threadsLightingParams([
{ id: 0, color: "#D97757" },
{ id: 1, brightness: 0 },
@@ -55,7 +55,7 @@ test("threadsLightingParams compose un tableau d'entrées", () => {
assert.deepEqual(params, [{ id: 0, c: 0xd97757 }, { id: 1, b: 0 }]);
});
-test("une zone rgbcfg porte les cinq champs minimisés", () => {
+test("an rgbcfg zone carries all five minified fields", () => {
assert.deepEqual(
zoneSide({ effect: EFFECTS.solid, brightness: 1, speed: 0.5, magic: 1, color: "#D97757" }),
{ e: 1, b: 1, s: 0.5, m: 1, c: 0xd97757 },
@@ -68,7 +68,7 @@ test("une zone rgbcfg porte les cinq champs minimisés", () => {
assert.equal(config.keys.c, 0xd97757);
});
-test("les six emplacements deviennent des entrées d'éclairage d'état", () => {
+test("the six slots become state lighting entries", () => {
const rows = [
{ state: STATES.running },
{ state: STATES.blocked },
@@ -82,17 +82,17 @@ test("les six emplacements deviennent des entrées d'éclairage d'état", () =>
assert.deepEqual(entries[0], { id: 0, c: 0xd97757, b: 1, e: EFFECTS.solid });
assert.deepEqual(entries[1], { id: 1, c: 0xc2483d, b: 1, e: EFFECTS.solid });
assert.deepEqual(entries[4], { id: 4, c: 0x2f2927, b: 1, e: EFFECTS.solid });
- // Un emplacement libre est éteint, sans toucher à la couleur.
+ // A free slot is unlit, without touching the colour.
assert.deepEqual(entries[5], { id: 5, b: 0 });
- // La couleur d'état utilisée est bien celle de la palette thread-status.
+ // The state colour used is the one from the thread-status palette.
assert.equal(entries[2].c, colorToInt(STATE_COLORS.idle));
});
test("slotsToThreadEntries exige exactement six lignes", () => {
- assert.throws(() => slotsToThreadEntries([{ state: STATES.free }]), /6 emplacements/);
- assert.throws(() => slotsToThreadEntries(null), /6 emplacements/);
+ assert.throws(() => slotsToThreadEntries([{ state: STATES.free }]), /Expected 6 slots/);
+ assert.throws(() => slotsToThreadEntries(null), /Expected 6 slots/);
});
-test("allOffParams éteint les six emplacements sans autre champ", () => {
+test("allOffParams turns the six slots off with no other field", () => {
assert.deepEqual(allOffParams(), [0, 1, 2, 3, 4, 5].map((id) => ({ id, b: 0 })));
});
diff --git a/tests/input-layer.test.mjs b/tests/input-layer.test.mjs
index d1e724f..537d3db 100644
--- a/tests/input-layer.test.mjs
+++ b/tests/input-layer.test.mjs
@@ -166,6 +166,147 @@ test("validators reject a sensitive key and an unprotected native layer", async
assert.match(staleResult.errors.join(" "), /first-free layer selection is forbidden/);
});
+test("preset diagnostics cover every safety contract with actionable messages", async (t) => {
+ const original = await loadPreset(path.join(projectRoot, "profiles", "claude-shortcuts"));
+ const cases = [
+ {
+ name: "manifest identity and compatibility",
+ mutate({ manifest }) {
+ manifest.formatVersion = "2.0.0";
+ manifest.status = "unknown";
+ manifest.target.platform = "Linux";
+ manifest.target.application.bundleId = "wrong.app";
+ manifest.compatibility.input.bundleId = "wrong.input";
+ manifest.compatibility.input.supportedVersions = [];
+ },
+ expected: [
+ /formatVersion must be 1\.0\.0/,
+ /Unsupported preset status/,
+ /target\.platform must be macOS/,
+ /Claude Desktop bundle identifier/,
+ /Input bundle identifier/,
+ /supportedVersions must include/,
+ ],
+ },
+ {
+ name: "preservation and installation",
+ mutate({ manifest }) {
+ manifest.preservation.protectedLayerIndexes = [];
+ manifest.preservation.replaceExisting = true;
+ manifest.preservation.targetLayerPolicy = "first-free";
+ manifest.preservation.targetLayerName = "Other";
+ manifest.preservation.targetAppSenseLink = "replace";
+ manifest.preservation.maxLayers = 7;
+ manifest.preservation.otherProfiles = "changed";
+ manifest.preservation.otherLayers = "changed";
+ manifest.preservation.otherAppSenseLinks = "changed";
+ manifest.installation.mechanism = "direct-write";
+ manifest.installation.applyMode = "automatic";
+ manifest.installation.sourceArtifact = "private-storage";
+ manifest.installation.outputArtifact = "storage-patch";
+ manifest.validation.required = [];
+ },
+ expected: [
+ /index 0 must be protected/,
+ /replaceExisting must be false/,
+ /targetLayerPolicy/,
+ /targetLayerName must be Claude/,
+ /AppSense link must be preserved/,
+ /maxLayers must be 6/,
+ /otherProfiles must be unchanged/,
+ /otherLayers must be unchanged/,
+ /otherAppSenseLinks must be unchanged/,
+ /local profile transform/,
+ /applyMode must be guided-ui/,
+ /sourceArtifact/,
+ /outputArtifact/,
+ /existing Claude layer/,
+ ],
+ },
+ {
+ name: "artifact evidence",
+ mutate({ manifest }) {
+ manifest.installation.layerArtifact = "artifacts/Claude-layer.json";
+ manifest.installation.layerArtifactSha256 = "INVALID";
+ manifest.installation.layerArtifactStatus = "roundtrip-verified";
+ manifest.validation.completed = [];
+ },
+ expected: [
+ /exact lowercase SHA-256/,
+ /official-layer-export-roundtrip evidence/,
+ ],
+ warning: /layer artifact is declared/i,
+ },
+ {
+ name: "mapping identity and controls",
+ mutate({ manifest, mapping }) {
+ mapping.formatVersion = "2.0.0";
+ mapping.presetId = `${manifest.id}-other`;
+ mapping.layer.rgb.hex = "orange";
+ mapping.layer.name = "Other";
+ mapping.controls[1].id = mapping.controls[0].id;
+ mapping.controls = mapping.controls.filter((control) => control.id !== "command-row-right");
+ mapping.controls.find((control) => control.id === "command-row-center-right").action = {
+ type: "shortcut",
+ keys: ["Meta", "X"],
+ };
+ mapping.controls.find((control) => control.id === "encoder-rotate").action = {};
+ mapping.controls.find((control) => control.id === "joystick").action = {};
+ mapping.unusedControls = [];
+ },
+ expected: [
+ /mapping\.formatVersion/,
+ /mapping\.presetId/,
+ /layer RGB/,
+ /layer name/,
+ /duplicate control id/,
+ /missing required control/,
+ /command-row-center-right must exactly match/,
+ /encoder rotation/,
+ /joystick action/,
+ /unused controls/,
+ ],
+ },
+ {
+ name: "activation and exclusions",
+ mutate({ mapping }) {
+ mapping.activation = {
+ type: "manual",
+ application: { bundleId: "wrong.app" },
+ detection: "none",
+ linkPolicy: "replace",
+ duplicatePolicy: "allow",
+ };
+ mapping.excludedByDefault = [];
+ },
+ expected: [
+ /activation must use AppSense/,
+ /AppSense bundle identifier/,
+ /existing link/,
+ /linkPolicy/,
+ /duplicate AppSense links/,
+ /excludedByDefault must include send-message/,
+ /excludedByDefault must include destructive-command/,
+ ],
+ },
+ ];
+
+ for (const scenario of cases) {
+ await t.test(scenario.name, () => {
+ const value = {
+ manifest: structuredClone(original.manifest),
+ mapping: structuredClone(original.mapping),
+ };
+ scenario.mutate(value);
+ const result = validatePreset(value.manifest, value.mapping);
+ const messages = result.errors.join("\n");
+ assert.equal(result.ok, false);
+ for (const expected of scenario.expected) assert.match(messages, expected);
+ if (scenario.warning) assert.match(result.warnings.join("\n"), scenario.warning);
+ });
+ }
+});
+
test("official profile export inventory protects index 0 and finds one existing Claude layer", async (t) => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "codex-inventory-"));
t.after(() => fs.rm(temp, { recursive: true, force: true }));
diff --git a/tests/input-profile.test.mjs b/tests/input-profile.test.mjs
index 1105e89..2b29820 100644
--- a/tests/input-profile.test.mjs
+++ b/tests/input-profile.test.mjs
@@ -9,89 +9,10 @@ import {
deriveMappingFromProfile,
FORBIDDEN_KEYS,
inspectInputProfile,
+ JOYSTICK_DIRECTION_COUNTS,
+ radialSectorGeometry,
} from "../shared/input-profile.mjs";
-
-function sourceProfile() {
- return {
- keyboard: "codex_micro",
- language: "us",
- profile: {
- id: 0,
- name: "Default",
- layers: [
- {
- id: 0,
- name: "Layer 1",
- layout: {
- encoders: [[
- { keycode: "KV_OAI_ENC_CC" },
- { keycode: "KV_OAI_ENC_CW" },
- { keycode: "KV_OAI_ENC_CLK" },
- ]],
- joystick: { type: "VENDOR", sectors: [] },
- base: [[{ keycode: "KV_0" }]],
- },
- },
- {
- id: 1,
- name: "Claude",
- linkedAppId: 7,
- layout: {
- encoders: [[
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- ]],
- joystick: {
- type: "RADIAL",
- sectors: [
- { k: "KI_X", a1: 0.1875, a2: 0.3125 },
- { k: "KC_NONE", a1: 0.3125, a2: 0.1875 },
- ],
- },
- base: [
- [{ keycode: "KC_NONE" }, { keycode: "KC_NONE" }],
- [
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- ],
- [
- { keycode: "KA_0" },
- { keycode: "KA_1" },
- { keycode: "KA_2" },
- { keycode: "KC_ESC" },
- ],
- [
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- { keycode: "KC_NONE" },
- ],
- ],
- },
- },
- ],
- },
- actions: [
- {
- id: 0,
- name: "Claude New",
- color: null,
- keyInputs: [
- { keycode: "KC_LGUI", delay: 0, actionType: 1 },
- { keycode: "KC_N", delay: 0, actionType: 2 },
- { keycode: "KC_LGUI", delay: 0, actionType: 0 },
- ],
- },
- ],
- multiactions: [],
- smartActions: [],
- actionGroups: [{ id: 0, name: "Default", actionIds: [0] }],
- multiactionGroups: [],
- smartActionGroups: [],
- };
-}
+import { sourceProfile } from "./helpers/input-profile-fixture.mjs";
test("recognizes exactly one non-native Claude layer with AppSense", () => {
const report = inspectInputProfile(sourceProfile());
@@ -114,8 +35,8 @@ test("builds the canonical mapping without touching the source or native layer",
claude.layout.base[2].map((entry) => entry.keycode),
["KA_0", "KA_1", "KA_2", "KC_ESC"],
);
- // La molette est en mode Effort par défaut : les deux sens référencent donc
- // une action générée, pas un keycode direct.
+ // The wheel is in Effort mode by default, so both directions reference a
+ // generated action rather than a direct keycode.
const defaultEncoder = claude.layout.encoders[0].map((entry) => entry.keycode);
assert.match(defaultEncoder[0], /^KA_\d+$/);
assert.match(defaultEncoder[1], /^KA_\d+$/);
@@ -172,8 +93,8 @@ test("forces the Claude AppSense link even when the source has lost it", () => {
assert.equal(report.appSenseId, 4);
assert.equal(report.appSenseForced, true);
assert.equal(report.baseLayerAppSenseId, null);
- // Forcer le lien dispense de l'exiger dans la source, sans avoir à passer
- // requireAppSense: false.
+ // Forcing the link removes the need to require it in the source, without
+ // having to pass requireAppSense: false.
assert.equal(report.appSenseLinked, false);
});
@@ -209,7 +130,7 @@ test("rejects invalid or colliding AppSense ids", () => {
() => buildInputProfile(sourceProfile(), DEFAULT_MAPPING, { baseLayerAppSenseId: "1" }),
/baseLayerAppSenseId/,
);
- // Deux layers liés à la même application rendraient la bascule ambiguë.
+ // Two layers linked to the same application would make the switch ambiguous.
assert.throws(
() =>
buildInputProfile(sourceProfile(), DEFAULT_MAPPING, {
@@ -388,6 +309,11 @@ test("supports the additional wheel modes", () => {
);
assert.equal(effortUp.keyInputs[5].keycode, "KC_RGHT");
assert.equal(deriveMappingFromProfile(effort.profile).mapping.wheel, "effort");
+
+ assert.throws(
+ () => buildInputProfile(sourceProfile(), { ...DEFAULT_MAPPING, wheel: "toString" }),
+ { code: "UNKNOWN_ASSIGNMENT" },
+ );
});
test("maps all 13 switches while preserving the layer sensor", () => {
@@ -587,8 +513,8 @@ test("assigns Claude actions to four joystick directions", () => {
});
const { sectors } = profile.profile.layers[1].layout.joystick;
- // Le premier secteur reste la zone de fermeture, les quatre suivants portent
- // des références d'action et non des keycodes nus.
+ // The first sector stays the close zone; the next four carry action
+ // references rather than bare keycodes.
assert.equal(sectors.length, 5);
assert.equal(sectors[0].k, "KI_X");
for (const sector of sectors.slice(1)) {
@@ -607,6 +533,16 @@ test("assigns Claude actions to four joystick directions", () => {
]);
});
+test("shares the exact joystick geometry with every consumer", () => {
+ assert.deepEqual([...JOYSTICK_DIRECTION_COUNTS], [4, 8]);
+ const geometry = radialSectorGeometry(4);
+ assert.deepEqual(geometry.close, { a1: 0.1875, a2: 0.3125 });
+ assert.equal(geometry.sectors.length, 4);
+ assert.equal(geometry.sectors[0].a1, geometry.close.a2);
+ assert.equal(geometry.sectors.at(-1).a2, geometry.close.a1);
+ assert.throws(() => radialSectorGeometry(0), { code: "JOYSTICK_SECTOR_COUNT" });
+});
+
test("supports eight joystick directions and round-trips them", () => {
const sectorsIn = [
"newSession",
diff --git a/tests/thread-slots.test.mjs b/tests/thread-slots.test.mjs
index f3d5b76..15d4336 100644
--- a/tests/thread-slots.test.mjs
+++ b/tests/thread-slots.test.mjs
@@ -29,14 +29,27 @@ function withRoster(rows, snapshot = emptySnapshot(), now = 1) {
return applyRoster(snapshot, rows, now);
}
-test("les événements de hook se traduisent en états", () => {
+function fullRunningRoster() {
+ const rows = Array.from({ length: SLOT_COUNT }, (_, index) => rosterRow(index + 1));
+ let snapshot = withRoster(rows).snapshot;
+ for (const row of rows) {
+ snapshot = applyHookEvent(
+ snapshot,
+ { event: "UserPromptSubmit", sessionId: row.sessionId },
+ 2,
+ ).snapshot;
+ }
+ return { rows, snapshot };
+}
+
+test("hook events translate into states", () => {
assert.equal(stateFromHookEvent({ event: "SessionStart" }), STATES.idle);
assert.equal(stateFromHookEvent({ event: "UserPromptSubmit" }), STATES.running);
assert.equal(stateFromHookEvent({ event: "Stop" }), STATES.done);
assert.equal(stateFromHookEvent({ event: "SessionEnd" }), STATES.ended);
});
-test("seules les notifications qui attendent une personne bloquent", () => {
+test("only notifications that wait on a person block", () => {
for (const notificationType of ["permission_prompt", "agent_needs_input", "elicitation_dialog"]) {
assert.equal(stateFromHookEvent({ event: "Notification", notificationType }), STATES.blocked);
}
@@ -44,13 +57,13 @@ test("seules les notifications qui attendent une personne bloquent", () => {
stateFromHookEvent({ event: "Notification", notificationType: "idle_prompt" }),
STATES.idle,
);
- // Un témoin rouge doit signifier « on t'attend » : ces types ne changent rien.
+ // A red light must mean "you are being waited for": these types change nothing.
for (const notificationType of ["auth_success", "elicitation_complete", "agent_completed"]) {
assert.equal(stateFromHookEvent({ event: "Notification", notificationType }), null);
}
});
-test("le roster ouvre les emplacements dans l'ordre et rafraîchit les métadonnées", () => {
+test("the roster opens slots in order and refreshes the metadata", () => {
const first = withRoster([rosterRow(1), rosterRow(2)]);
const view = slotView(first.snapshot);
@@ -65,7 +78,7 @@ test("le roster ouvre les emplacements dans l'ordre et rafraîchit les métadonn
assert.equal(renamed.snapshot.slots[0].sessionId, "session-1", "l'emplacement reste collant");
});
-test("un état reçu avant le roster est mis en attente puis promu", () => {
+test("a state received before the roster is held pending, then promoted", () => {
const early = applyHookEvent(emptySnapshot(), { event: "UserPromptSubmit", sessionId: "session-1" }, 1);
assert.equal(early.changed, false, "aucun emplacement n'est ouvert par un hook seul");
assert.equal(early.snapshot.pending["session-1"], STATES.running);
@@ -76,9 +89,9 @@ test("un état reçu avant le roster est mis en attente puis promu", () => {
assert.equal("session-1" in confirmed.snapshot.pending, false);
});
-test("une session absente du roster ne prend jamais d'emplacement", () => {
- // Mesuré : les sessions `claude -p` et les sous-agents émettent des hooks sans
- // apparaître dans `claude agents --json`.
+test("a session missing from the roster never takes a slot", () => {
+ // Measured: `claude -p` sessions and subagents emit hooks without ever showing
+ // up in `claude agents --json`.
let snapshot = emptySnapshot();
for (let index = 0; index < 50; index += 1) {
snapshot = applyHookEvent(snapshot, { event: "Stop", sessionId: `fantome-${index}` }, index).snapshot;
@@ -88,29 +101,24 @@ test("une session absente du roster ne prend jamais d'emplacement", () => {
assert.ok(snapshot.dropped > 0, "les abandons sont comptés, pas silencieux");
});
-test("un hook manqué est rattrapé par la disparition du roster", () => {
+test("a missed hook is caught by the session leaving the roster", () => {
const live = withRoster([rosterRow(1)]);
const busy = applyHookEvent(live.snapshot, { event: "UserPromptSubmit", sessionId: "session-1" }, 2);
assert.equal(busy.snapshot.slots[0].state, STATES.running);
- // Le processus meurt sans émettre Stop ni SessionEnd.
+ // The process dies without emitting Stop or SessionEnd.
const gone = withRoster([], busy.snapshot, 3);
assert.equal(gone.changed, true);
assert.equal(gone.snapshot.slots[0].state, STATES.ended);
assert.equal(gone.snapshot.slots[0].sessionId, "session-1", "l'emplacement reste consultable");
});
-test("une session fermée est évincée avant toute session vivante", () => {
- let snapshot = emptySnapshot();
- const rows = Array.from({ length: SLOT_COUNT }, (_, index) => rosterRow(index + 1));
- snapshot = withRoster(rows, snapshot).snapshot;
- for (const row of rows) {
- snapshot = applyHookEvent(snapshot, { event: "UserPromptSubmit", sessionId: row.sessionId }, 2).snapshot;
- }
+test("a closed session is evicted before any live one", () => {
+ const { rows, snapshot: fullSnapshot } = fullRunningRoster();
- // La troisième meurt, une septième arrive : elle doit prendre cet emplacement.
+ // The third one dies and a seventh arrives: it must take that slot.
const survivors = rows.filter((row) => row.sessionId !== "session-3");
- snapshot = withRoster(survivors, snapshot, 3).snapshot;
+ let snapshot = withRoster(survivors, fullSnapshot, 3).snapshot;
assert.equal(snapshot.slots[2].state, STATES.ended);
const crowded = withRoster([...survivors, rosterRow(7)], snapshot, 4);
@@ -123,13 +131,8 @@ test("une session fermée est évincée avant toute session vivante", () => {
);
});
-test("le débordement est signalé au lieu d'être tronqué en silence", () => {
- let snapshot = emptySnapshot();
- const rows = Array.from({ length: SLOT_COUNT }, (_, index) => rosterRow(index + 1));
- snapshot = withRoster(rows, snapshot).snapshot;
- for (const row of rows) {
- snapshot = applyHookEvent(snapshot, { event: "UserPromptSubmit", sessionId: row.sessionId }, 2).snapshot;
- }
+test("overflow is reported instead of being silently truncated", () => {
+ const { rows, snapshot } = fullRunningRoster();
const overflow = withRoster([...rows, rosterRow(7)], snapshot, 3);
assert.equal(overflow.snapshot.overflow, 1);
@@ -137,7 +140,7 @@ test("le débordement est signalé au lieu d'être tronqué en silence", () => {
assert.equal(overflow.snapshot.slots.some((entry) => entry?.sessionId === "session-7"), false);
});
-test("la navigation ne suppose aucune route", () => {
+test("navigation assumes no route", () => {
assert.equal(resolveNavigation(null).kind, "empty");
const terminal = resolveNavigation({
@@ -152,9 +155,9 @@ test("la navigation ne suppose aucune route", () => {
assert.equal(closed.kind, "resume");
assert.equal(closed.sessionId, "session-1");
- // Session hébergée par Claude Desktop : pas de tty. `claude://resume` la
- // désigne par le `sessionId` du roster — jamais par le `hostSessionId`, qui
- // regroupe plusieurs sessions.
+ // Session hosted by Claude Desktop: no tty. `claude://resume` addresses it by
+ // the roster's `sessionId` — never by the `hostSessionId`, which groups
+ // several sessions together.
const hosted = resolveNavigation({
sessionId: "6f2d3f4a-8c11-4b2e-9a77-0d5e1c8b4a30",
state: STATES.running,
@@ -165,8 +168,8 @@ test("la navigation ne suppose aucune route", () => {
assert.equal(hosted.url, "claude://resume?session=6f2d3f4a-8c11-4b2e-9a77-0d5e1c8b4a30");
assert.equal(hosted.hostSessionId, "local_f92b6e6a");
- // L'application valide la cible par une regex UUID stricte. Un identifiant
- // d'une autre forme ne donne pas lieu à une URL que le handler refuserait.
+ // The application validates the target against a strict UUID regex. An id of
+ // any other shape yields no URL that the handler would reject.
const opaque = resolveNavigation({
sessionId: "session-1",
state: STATES.running,
@@ -176,21 +179,21 @@ test("la navigation ne suppose aucune route", () => {
assert.equal(opaque.url, undefined);
});
-test("le tty de `ps` devient un chemin de périphérique qui existe", () => {
- // La forme que renvoie macOS. Un préfixe `/dev/tty` inconditionnel donnait
- // `/dev/ttyttys001`, et le focus AppleScript ne trouvait jamais la fenêtre.
+test("the tty from `ps` becomes a device path that exists", () => {
+ // The form macOS returns. An unconditional `/dev/tty` prefix produced
+ // `/dev/ttyttys001`, and the AppleScript focus never found the window.
assert.equal(ttyDevice("ttys001"), "/dev/ttys001");
- // La forme courte des autres BSD, qui exige bien le préfixe complet.
+ // The short form of other BSDs, which does need the full prefix.
assert.equal(ttyDevice("s001"), "/dev/ttys001");
- // Déjà absolu : conservé tel quel, sans double préfixe.
+ // Already absolute: kept as is, with no double prefix.
assert.equal(ttyDevice("/dev/ttys006"), "/dev/ttys006");
- // Sessions sans terminal : Claude Desktop, un IDE, un `claude -p`.
+ // Sessions with no terminal: Claude Desktop, an IDE, a `claude -p`.
for (const absent of ["??", "-", "", null, undefined]) {
assert.equal(ttyDevice(absent), null);
}
});
-test("un instantané corrompu retombe sur six emplacements libres", () => {
+test("a corrupted snapshot falls back to six free slots", () => {
assert.equal(normalizeSnapshot(null).slots.length, SLOT_COUNT);
assert.equal(normalizeSnapshot({ slots: "nope" }).slots.length, SLOT_COUNT);
const partial = normalizeSnapshot({ slots: [{ sessionId: "session-1", state: STATES.done }, 42] });
diff --git a/thread-status/bin/emit.mjs b/thread-status/bin/emit.mjs
index f485ec0..bdf1789 100755
--- a/thread-status/bin/emit.mjs
+++ b/thread-status/bin/emit.mjs
@@ -1,39 +1,39 @@
#!/usr/bin/env node
-// Émetteur de hook. Lit l'événement sur stdin, y joint l'identité du processus
-// prise dans l'environnement, et ajoute une ligne NDJSON au journal.
+// Hook emitter. Reads the event on stdin, attaches the process identity taken
+// from the environment, and appends one NDJSON line to the journal.
//
-// Trois contraintes dictent la forme de ce fichier :
+// Three constraints dictate the shape of this file:
//
-// 1. Il ne doit rien écrire sur stdout. La sortie d'un hook UserPromptSubmit
-// est injectée dans le contexte de la conversation : un journal bavard
-// finirait dans le prompt de l'utilisateur.
-// 2. Il doit toujours sortir avec le code 0. Un hook en échec remonte une
-// erreur dans la session, pour un témoin lumineux qui n'a rien d'essentiel.
-// 3. Il ne doit dépendre de rien. Le plugin est distribuable seul, sans le
-// dépôt : la résolution du chemin du journal est donc dupliquée ici et dans
-// scripts/thread-status.mjs, qui en est le seul autre lecteur.
+// 1. It must write nothing to stdout. The output of a UserPromptSubmit hook is
+// injected into the conversation context: a chatty journal would end up in
+// the user's prompt.
+// 2. It must always exit with code 0. A failing hook raises an error in the
+// session, over a status light that is not essential to anything.
+// 3. It must depend on nothing. The plugin is distributable on its own,
+// without the repository: journal path resolution is therefore duplicated
+// here and in scripts/thread-status.mjs, its only other reader.
import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
-// Le contrat de chemin, dupliqué : voir contrainte 3.
+// The path contract, duplicated: see constraint 3.
const STATE_DIR =
process.env.CLAUDE_THREAD_STATUS_DIR || path.join(os.homedir(), ".claude", "thread-status");
const JOURNAL = path.join(STATE_DIR, "events.ndjson");
const JOURNAL_MAX_BYTES = 4 * 1024 * 1024;
-// CLAUDE_PLUGIN_DATA n'est pas utilisé comme racine d'état : sa valeur diffère
-// selon le mode de chargement du plugin (`…/data/-inline` avec
-// --plugin-dir, `…/data/` après installation), ce qui perdrait les
-// emplacements entre le développement et l'usage réel.
+// CLAUDE_PLUGIN_DATA is not used as the state root: its value differs with how
+// the plugin is loaded (`…/data/-inline` under --plugin-dir,
+// `…/data/` once installed), which would lose the slots between
+// development and real use.
function rotate() {
try {
if (statSync(JOURNAL).size > JOURNAL_MAX_BYTES) renameSync(JOURNAL, `${JOURNAL}.1`);
} catch {
- // Journal absent ou rotation impossible : l'append qui suit le recréera.
+ // Journal missing, or rotation impossible: the append below recreates it.
}
}
@@ -55,9 +55,8 @@ function main() {
hostSessionId: process.env.CLAUDE_CODE_HOST_SESSION_ID ?? null,
};
- // Seuls les champs qui portent une transition sont conservés. Aucun contenu de
- // message, aucun chemin de transcript, aucune entrée de tool : le journal doit
- // rester publiable tel quel.
+ // Only the fields that carry a transition are kept. No message content, no
+ // transcript path, no tool input: the journal has to stay publishable as is.
if (payload.notification_type) record.notificationType = payload.notification_type;
if (payload.stop_reason) record.stopReason = payload.stop_reason;
if (payload.reason) record.reason = payload.reason;
@@ -73,6 +72,6 @@ function main() {
try {
main();
} catch {
- // Contrainte 2 : aucune erreur ne remonte dans la session.
+ // Constraint 2: no error ever surfaces in the session.
}
process.exit(0);
From 58315318de8639950503e5f8ba7d4650189ec1be Mon Sep 17 00:00:00 2001
From: Thanh Chau <1320427+thannous@users.noreply.github.com>
Date: Fri, 31 Jul 2026 14:16:15 +0200
Subject: [PATCH 2/4] Allow clean GUI setup in CLI tests
---
tests/cli.test.mjs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs
index cb0bb0c..1938eff 100644
--- a/tests/cli.test.mjs
+++ b/tests/cli.test.mjs
@@ -53,7 +53,6 @@ test("repository validation commands run successfully from the checkout", async
"scripts/validate-profile.mjs",
"scripts/validate-presets.mjs",
"scripts/check-doc-links.mjs",
- "scripts/prepare-gui.mjs",
]) {
await t.test(script, () => {
const result = runScript(script);
@@ -61,6 +60,12 @@ test("repository validation commands run successfully from the checkout", async
assert.match(result.stdout, /OK:/);
});
}
+
+ await t.test("scripts/prepare-gui.mjs", () => {
+ const result = runScript("scripts/prepare-gui.mjs", [], { timeout: 120_000 });
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /OK:/);
+ });
});
test("build-input-profile writes a validated profile once and refuses overwrite", async (t) => {
From 41e2dded14072dd7f75a3686ab90d49484ee96dd Mon Sep 17 00:00:00 2001
From: Thanh Chau <1320427+thannous@users.noreply.github.com>
Date: Fri, 31 Jul 2026 14:31:35 +0200
Subject: [PATCH 3/4] fix: address architecture review findings
---
prototype/src/App.jsx | 17 ++--
.../src/components/ConfiguratorWorkspace.jsx | 10 ++-
prototype/src/components/JoystickDial.jsx | 5 +-
.../src/components/KeyAssignmentEditor.jsx | 9 +-
prototype/src/components/MappingDialog.jsx | 83 +++++++++++++++----
prototype/src/components/ProfileReview.jsx | 7 +-
prototype/src/configurator-presenter.js | 13 ++-
prototype/src/configurator-state.js | 4 +-
prototype/src/hooks/use-theme.js | 19 +++--
prototype/src/i18n/de.js | 3 +
prototype/src/i18n/en.js | 3 +
prototype/src/i18n/es.js | 3 +
prototype/src/i18n/fr.js | 3 +
prototype/src/profile-panel-loader.js | 9 +-
prototype/src/profile-workflow.js | 2 +-
prototype/src/retryable-loader.js | 15 ++++
prototype/src/styles.css | 24 ++++--
prototype/tests/app-render.test.mjs | 9 +-
prototype/tests/configurator-state.test.mjs | 10 ++-
prototype/tests/presentation.test.mjs | 24 +++++-
prototype/tests/retryable-loader.test.mjs | 40 +++++++++
scripts/configure.mjs | 2 +-
scripts/lib/hid-device.mjs | 2 +-
tests/cli.test.mjs | 6 --
tests/hid-device.test.mjs | 3 +-
tests/hid-lighting.test.mjs | 2 +-
tests/input-layer.test.mjs | 19 ++++-
tests/thread-slots.test.mjs | 2 +-
28 files changed, 269 insertions(+), 79 deletions(-)
create mode 100644 prototype/src/retryable-loader.js
create mode 100644 prototype/tests/retryable-loader.test.mjs
diff --git a/prototype/src/App.jsx b/prototype/src/App.jsx
index 8b75dae..9a91d98 100644
--- a/prototype/src/App.jsx
+++ b/prototype/src/App.jsx
@@ -41,15 +41,18 @@ import {
profileSessionReducer,
} from "./profile-session.js";
import { loadProfileExportPanel } from "./profile-panel-loader.js";
+import { createRetryableLoader } from "./retryable-loader.js";
import { useTheme } from "./hooks/use-theme.js";
const DEFAULT_CONTROL_ID = "key-1";
const TOAST_DURATION_MS = 6000;
-let profileWorkflowPromise;
+const loadProfileWorkflow = createRetryableLoader(
+ () => import("./profile-workflow.js"),
+);
-function loadProfileWorkflow() {
- profileWorkflowPromise ??= import("./profile-workflow.js");
- return profileWorkflowPromise;
+function prefetchProfileModules() {
+ loadProfileWorkflow().catch(() => {});
+ loadProfileExportPanel().catch(() => {});
}
export function App() {
@@ -281,15 +284,13 @@ export function App() {
}, [mapping, profile.appSenseIds, profile.source, scrollToLoader, scrollToReview, t]);
const switchToExport = useCallback(() => {
- void loadProfileWorkflow();
- void loadProfileExportPanel();
+ prefetchProfileModules();
setPanelMode("export");
if (profile.source) void runReview();
}, [profile.source, runReview]);
const openReviewFromHero = useCallback(() => {
- void loadProfileWorkflow();
- void loadProfileExportPanel();
+ prefetchProfileModules();
returnFocusRef.current = document.activeElement;
setPanelOpen(true);
setPanelMode("export");
diff --git a/prototype/src/components/ConfiguratorWorkspace.jsx b/prototype/src/components/ConfiguratorWorkspace.jsx
index fd3ac8a..b9f79f2 100644
--- a/prototype/src/components/ConfiguratorWorkspace.jsx
+++ b/prototype/src/components/ConfiguratorWorkspace.jsx
@@ -145,6 +145,7 @@ export const ConfiguratorWorkspace = memo(function ConfiguratorWorkspace({
return (