From 512902459af2cac3d6838ba0d5d7440cab68bced Mon Sep 17 00:00:00 2001 From: Brandon Joyce Date: Fri, 7 Aug 2026 11:27:54 -0400 Subject: [PATCH 1/6] feat: focus Codex on local agent press --- README.md | 5 +++++ docs/MACOS.md | 6 ++++++ src/codex-focus.ts | 22 ++++++++++++++++++++++ src/controller.ts | 18 ++++++++++++++++-- src/plugin.ts | 4 +++- static/property-inspector/agent.html | 17 ++++++++++++++++- test/codex-open.test.ts | 12 ++++++++++++ test/ios-project.test.ts | 1 + 8 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/codex-focus.ts diff --git a/README.md b/README.md index 7ec5d7c..a94c72a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Windows-only and Mac-only mode have no relay, no second computer dependency, and ## Features - Six dynamic agent keys using the source and assignments selected in **Codex Settings > Codex Micro**. +- Optional macOS focus on local agent presses, so Codex receives subsequent keyboard shortcuts even when another app was active. - Live idle, working, unread completion, approval/input, error, and empty states. - Codex-aligned light and dark rendering with restrained status animation. - Native key-down/key-up handling for Micro slots `ACT06` through `ACT12`. @@ -94,6 +95,10 @@ The action names describe the default Codex Micro setup. The keys always follow The page-navigation and profile-switch keys are built-in Stream Deck actions. All other named controls come from Codex Deck. Every official Codex Micro keycap is also exposed as a standalone action, so extra pages can be customized without changing the six synchronized Micro action slots. +### Optional macOS app focus + +The **Agent Display** inspector includes a global **Focus Codex** setting for the six agent keys. It defaults to off. On a local Mac, enabling it activates Codex before the agent action, so keyboard shortcuts such as approval target Codex rather than the previously active app. It never focuses the local app for an agent routed to a remote host. For an environment default, set `CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS=true` before starting Stream Deck; an explicit inspector choice takes precedence. + ### Usage and reset controls ![Usage limit, overview, and reset-credit controls](docs/assets/usage-controls-preview.svg) diff --git a/docs/MACOS.md b/docs/MACOS.md index c71f743..60873bb 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -39,6 +39,12 @@ Tailscale remote access remains a separate optional profile; see **Start Codex Deck.command** is the double-clickable equivalent of `start`. 5. Open **Codex Settings > Codex Micro**, configure the native slots, and add the actions from the [recommended layout](../README.md#recommended-15-key-layout). Leave the Windows/Mac target position empty or replace it with another action. +### Focus Codex when opening an agent + +Each agent key's **Agent Display** inspector has a global **Focus Codex** option. It is off by default. When enabled, a local Mac agent press activates Codex before its native Micro event is sent, so approval and other keyboard shortcuts target Codex instead of the previously active app. Remote agents never activate the local app. + +For a local installation or test environment, set `CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS=true` before starting Stream Deck; it supplies the initial default until the inspector setting is saved. + If an archive tool removed executable permissions, restore only the two launcher files: ```zsh diff --git a/src/codex-focus.ts b/src/codex-focus.ts new file mode 100644 index 0000000..e53d2a8 --- /dev/null +++ b/src/codex-focus.ts @@ -0,0 +1,22 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export function focusCodexOnAgentPressDefault(env: NodeJS.ProcessEnv = process.env): boolean { + return env.CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS === "true"; +} + +export function codexFocusSpec(targetPlatform = process.platform): { executable: string; args: string[] } | null { + if (targetPlatform !== "darwin") return null; + return { + executable: "/usr/bin/open", + args: ["-b", "com.openai.codex"] + }; +} + +export async function focusCodexApp(): Promise { + const spec = codexFocusSpec(); + if (!spec) return; + await execFileAsync(spec.executable, spec.args, { windowsHide: true }); +} diff --git a/src/controller.ts b/src/controller.ts index ae08aac..db08f8c 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -17,6 +17,7 @@ import { renderRateLimitResetKey, renderUsageLimitKey, renderUsageOverviewKey, type BuiltinIconName } from "./render.js"; import { openCodexThread } from "./codex-open.js"; +import { focusCodexApp, focusCodexOnAgentPressDefault } from "./codex-focus.js"; import { visualStatusFromMicro } from "./status.js"; import type { CodexHost, HostHealth, MicroActionSlot, MicroDirection, MicroSnapshot, ReasoningAdjustment, @@ -33,7 +34,10 @@ type AgentRegistration = { action: KeyAction; slot: number }; type MicroActionRegistration = { action: KeyAction; slot: MicroActionSlot }; type UsageLimitRegistration = { action: KeyAction; mode: UsageLimitMode }; type ActionIdentity = { id: string }; -type ContextRingSettings = { showContextRings?: boolean }; +export type DeckSettings = { + showContextRings?: boolean; + focusCodexOnAgentPress?: boolean; +}; const USER_ICON_ROOT = join(codexDeckStateRoot(), "icons"); const LOCAL_MOBILE_CONFIG = "mobile-local-relay-server.json"; @@ -75,12 +79,14 @@ export class DeckController { private lastAgentSourceSignature = ""; private lastHostHealthSignature = ""; private showContextRings = true; + private focusCodexOnAgentPress = false; async start(): Promise { this.stopped = false; try { - const settings = await streamDeck.settings.getGlobalSettings(); + const settings = await streamDeck.settings.getGlobalSettings(); this.showContextRings = settings.showContextRings !== false; + this.focusCodexOnAgentPress = settings.focusCodexOnAgentPress ?? focusCodexOnAgentPressDefault(); } catch (error) { streamDeck.logger.warn(`Context-ring settings were unavailable; using enabled by default: ${String(error)}`); } @@ -189,6 +195,10 @@ export class DeckController { void Promise.all([...this.agents.values()].map((registration) => this.renderAgent(registration))); } + setFocusCodexOnAgentPress(enabled: boolean): void { + this.focusCodexOnAgentPress = enabled; + } + registerMicroAction(slot: MicroActionSlot, action: KeyAction): void { this.microActions.set(action.id, { action, slot }); void this.renderMicroAction({ action, slot }); @@ -295,6 +305,10 @@ export class DeckController { else this.pressedAgents.delete(slot); if (!assignment.threadKey) throw new Error("The selected Codex task has no stable thread identity."); if (assignment.host.hostId === this.localHost?.hostId) { + if (act === 1 && this.focusCodexOnAgentPress) { + try { await focusCodexApp(); } + catch (error) { streamDeck.logger.warn(`Could not focus Codex before opening agent ${slot + 1}: ${String(error)}`); } + } await this.microBridge.sendAgent(assignment.sourceSlot, act, assignment.threadKey); } else await this.sendRemote({ kind: "agent", slot: assignment.sourceSlot, threadKey: assignment.threadKey, act }); if (act === 0) void this.refresh(); diff --git a/src/plugin.ts b/src/plugin.ts index b276e2f..e6c200c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,6 @@ import streamDeck from "@elgato/streamdeck"; import { DeckController } from "./controller.js"; +import { focusCodexOnAgentPressDefault } from "./codex-focus.js"; import { Agent1, Agent2, Agent3, Agent4, Agent5, Agent6, Approve, Back, Decline, Dictation, Fast, Fork, Forward, NewTask, @@ -15,8 +16,9 @@ import { const controller = new DeckController(); -streamDeck.settings.onDidReceiveGlobalSettings<{ showContextRings?: boolean }>((event) => { +streamDeck.settings.onDidReceiveGlobalSettings<{ showContextRings?: boolean; focusCodexOnAgentPress?: boolean }>((event) => { controller.setContextRingVisibility(event.settings.showContextRings !== false); + controller.setFocusCodexOnAgentPress(event.settings.focusCodexOnAgentPress ?? focusCodexOnAgentPressDefault()); }); for (const pluginAction of [ diff --git a/static/property-inspector/agent.html b/static/property-inspector/agent.html index 7cc2e5e..8a4bfdc 100644 --- a/static/property-inspector/agent.html +++ b/static/property-inspector/agent.html @@ -8,6 +8,7 @@ :root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } body { margin: 0; padding: 14px; color: #d8d8d8; background: #2d2d2d; font-size: 12px; } label { display: grid; grid-template-columns: 92px 1fr; align-items: center; gap: 8px; cursor: pointer; } + label + label { margin-top: 10px; } input { width: 17px; height: 17px; margin: 0; accent-color: #1683ff; } p { margin: 12px 0 0 100px; color: #a9a9a9; line-height: 1.35; } @@ -17,7 +18,11 @@ Context ring -

This global option applies to all six Codex agent keys on this computer.

+ +

These global options apply to all six Codex agent keys on this computer. Focus Codex activates the local Mac app before opening an agent, so its keyboard shortcuts are ready.

diff --git a/test/codex-open.test.ts b/test/codex-open.test.ts index 17aac3e..5225413 100644 --- a/test/codex-open.test.ts +++ b/test/codex-open.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { codexOpenSpec, codexThreadUrl } from "../src/codex-open.js"; +import { codexFocusSpec, focusCodexOnAgentPressDefault } from "../src/codex-focus.js"; test("Codex task deep links only accept task UUIDs or new", () => { assert.equal(codexThreadUrl("new"), "codex://threads/new"); @@ -16,3 +17,14 @@ test("Codex links use native launchers on Windows and macOS", () => { assert.deepEqual(mac, { executable: "/usr/bin/open", args: ["codex://threads/new"], windowsHide: false }); assert.throws(() => codexOpenSpec("new", "linux"), /unsupported/); }); + +test("agent presses can opt into focusing the local macOS Codex app", () => { + assert.equal(focusCodexOnAgentPressDefault({}), false); + assert.equal(focusCodexOnAgentPressDefault({ CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS: "true" }), true); + assert.equal(focusCodexOnAgentPressDefault({ CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS: "1" }), false); + assert.deepEqual(codexFocusSpec("darwin"), { + executable: "/usr/bin/open", + args: ["-b", "com.openai.codex"] + }); + assert.equal(codexFocusSpec("win32"), null); +}); diff --git a/test/ios-project.test.ts b/test/ios-project.test.ts index 8092b37..05facfb 100644 --- a/test/ios-project.test.ts +++ b/test/ios-project.test.ts @@ -207,6 +207,7 @@ test("context rings are optional in both Stream Deck and the native iPhone app", assert.match(inspector, /getGlobalSettings/); assert.match(inspector, /setGlobalSettings/); assert.match(inspector, /showContextRings/); + assert.match(inspector, /focusCodexOnAgentPress/); assert.match(plugin, /onDidReceiveGlobalSettings/); assert.match(render, /data-context-used/); assert.match(settings, /Toggle\([\s\S]*"Context rings"/); From 24cff8245944573e002413b805bf93898f656e17 Mon Sep 17 00:00:00 2001 From: Brandon Joyce Date: Wed, 12 Aug 2026 14:12:25 -0400 Subject: [PATCH 2/6] docs: show macOS focus environment command --- README.md | 2 +- docs/MACOS.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a94c72a..75c24fd 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The page-navigation and profile-switch keys are built-in Stream Deck actions. Al ### Optional macOS app focus -The **Agent Display** inspector includes a global **Focus Codex** setting for the six agent keys. It defaults to off. On a local Mac, enabling it activates Codex before the agent action, so keyboard shortcuts such as approval target Codex rather than the previously active app. It never focuses the local app for an agent routed to a remote host. For an environment default, set `CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS=true` before starting Stream Deck; an explicit inspector choice takes precedence. +The **Agent Display** inspector includes a global **Focus Codex** setting for the six agent keys. It defaults to off. On a local Mac, enabling it activates Codex before the agent action, so keyboard shortcuts such as approval target Codex rather than the previously active app. It never focuses the local app for an agent routed to a remote host. For an environment default, run `launchctl setenv CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS true` before starting Stream Deck; an explicit inspector choice takes precedence. ### Usage and reset controls diff --git a/docs/MACOS.md b/docs/MACOS.md index 60873bb..f2adc5a 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -43,7 +43,13 @@ Tailscale remote access remains a separate optional profile; see Each agent key's **Agent Display** inspector has a global **Focus Codex** option. It is off by default. When enabled, a local Mac agent press activates Codex before its native Micro event is sent, so approval and other keyboard shortcuts target Codex instead of the previously active app. Remote agents never activate the local app. -For a local installation or test environment, set `CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS=true` before starting Stream Deck; it supplies the initial default until the inspector setting is saved. +For a local installation or test environment, set the initial default before starting Stream Deck: + +```zsh +launchctl setenv CODEX_DECK_FOCUS_CODEX_ON_AGENT_PRESS true +``` + +Then restart Stream Deck. An explicit inspector setting takes precedence. If an archive tool removed executable permissions, restore only the two launcher files: From b4c5bd489c791f442fbc165d87e352d97b4e5d72 Mon Sep 17 00:00:00 2001 From: Brandon Joyce Date: Wed, 12 Aug 2026 14:37:22 -0400 Subject: [PATCH 3/6] fix: harden global settings inspector --- src/controller.ts | 10 ++- static/property-inspector/agent.html | 52 +++++++---- test/property-inspector.test.ts | 129 +++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 test/property-inspector.test.ts diff --git a/src/controller.ts b/src/controller.ts index db08f8c..295bb64 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -87,8 +87,16 @@ export class DeckController { const settings = await streamDeck.settings.getGlobalSettings(); this.showContextRings = settings.showContextRings !== false; this.focusCodexOnAgentPress = settings.focusCodexOnAgentPress ?? focusCodexOnAgentPressDefault(); + if (settings.focusCodexOnAgentPress == null) { + void streamDeck.settings.setGlobalSettings({ + ...settings, + focusCodexOnAgentPress: this.focusCodexOnAgentPress + }).catch((error) => { + streamDeck.logger.warn(`Could not persist the effective Focus Codex setting: ${String(error)}`); + }); + } } catch (error) { - streamDeck.logger.warn(`Context-ring settings were unavailable; using enabled by default: ${String(error)}`); + streamDeck.logger.warn(`Global settings were unavailable; using defaults: ${String(error)}`); } this.localHost = await getOrCreateHostIdentity(); const persistedTarget = await readControlTarget(undefined, this.localHost.platform); diff --git a/static/property-inspector/agent.html b/static/property-inspector/agent.html index 8a4bfdc..4c91604 100644 --- a/static/property-inspector/agent.html +++ b/static/property-inspector/agent.html @@ -16,17 +16,38 @@

These global options apply to all six Codex agent keys on this computer. Focus Codex activates the local Mac app before opening an agent, so its keyboard shortcuts are ready.

diff --git a/test/property-inspector.test.ts b/test/property-inspector.test.ts new file mode 100644 index 0000000..1972f03 --- /dev/null +++ b/test/property-inspector.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +type EventHandler = (event: { data?: string; target?: FakeInput }) => void; + +class FakeInput { + checked = false; + disabled = true; + readonly handlers = new Map(); + + addEventListener(event: string, handler: EventHandler): void { + this.handlers.set(event, handler); + } + + dispatchChange(): void { + this.handlers.get("change")?.({ target: this }); + } +} + +class FakeWebSocket { + static readonly OPEN = 1; + static latest?: FakeWebSocket; + + readyState = 0; + readonly sent: string[] = []; + readonly handlers = new Map(); + + constructor(readonly url: string) { + FakeWebSocket.latest = this; + } + + addEventListener(event: string, handler: EventHandler): void { + this.handlers.set(event, handler); + } + + send(message: string): void { + this.sent.push(message); + } + + emit(event: string, data?: unknown): void { + this.handlers.get(event)?.({ data: data == null ? undefined : JSON.stringify(data) }); + } +} + +test("agent property inspector waits for and preserves complete global settings", async () => { + const html = await readFile(new URL("../static/property-inspector/agent.html", import.meta.url), "utf8"); + const script = html.match(/