diff --git a/README.md b/README.md index 7ec5d7c..67de17a 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 and Stream Deck persists the choice for the plugin. 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. + ### 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..26bb43f 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -39,6 +39,10 @@ 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, applies to all six agent keys, and is persisted by Stream Deck. 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. + If an archive tool removed executable permissions, restore only the two launcher files: ```zsh diff --git a/docs/RELEASE_0.7.0.5.md b/docs/RELEASE_0.7.0.5.md new file mode 100644 index 0000000..b0ce360 --- /dev/null +++ b/docs/RELEASE_0.7.0.5.md @@ -0,0 +1,32 @@ +# Codex Deck v0.7.0.5 + +This focused macOS hotfix adds an optional, persistent way to bring Codex to +the foreground before dispatching a local agent press. + +## Changes + +- Adds a global **Focus Codex** option to the Agent Display property inspector. +- Clearly identifies the focus option as macOS-only while keeping it harmless + on Windows. +- Persists the option with Stream Deck so it survives app and computer + restarts and applies consistently to all six agent keys. +- Activates Codex through its macOS bundle identifier before sending a local + agent press, allowing subsequent keyboard shortcuts to target Codex. +- Leaves remote-agent routing and Windows behavior unchanged. +- Prevents the property inspector from overwriting unrelated global settings + while its initial settings are loading. + +## Downloads + +- Stream Deck: `com.simeo.codex-deck.streamDeckPlugin` +- Windows launcher: `codex-deck-launcher-windows-v0.7.0.5.zip` +- macOS launcher: `codex-deck-launcher-macos-v0.7.0.5.zip` +- iPhone source: use the Source code archive or clone tag `v0.7.0.5`. +- Checksums: `SHA256SUMS.txt` + +Existing v0.7.0 launcher and watcher installations remain compatible. Stream +Deck users only need to install the updated plugin. Codex itself does not need +to restart. + +Codex Deck is an independent community project and is not made, supported, or +endorsed by OpenAI or Elgato. diff --git a/package-lock.json b/package-lock.json index e01f9f9..adec203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-stream-deck", - "version": "0.7.0-hotfix.2", + "version": "0.7.0-hotfix.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-stream-deck", - "version": "0.7.0-hotfix.2", + "version": "0.7.0-hotfix.5", "license": "MIT", "dependencies": { "@elgato/streamdeck": "2.1.0", diff --git a/package.json b/package.json index 2c1b4d7..8ce8b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-stream-deck", - "version": "0.7.0-hotfix.2", + "version": "0.7.0-hotfix.5", "private": false, "type": "module", "description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay", diff --git a/src/codex-focus.ts b/src/codex-focus.ts new file mode 100644 index 0000000..e29171d --- /dev/null +++ b/src/codex-focus.ts @@ -0,0 +1,18 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +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..acb7717 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 } 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,14 +79,16 @@ 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 === true; } 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); @@ -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..b439e5f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -15,8 +15,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 === true); }); for (const pluginAction of [ diff --git a/static/manifest.json b/static/manifest.json index c008fc7..497dc27 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json", "Name": "Codex Deck", - "Version": "0.7.0.2", + "Version": "0.7.0.5", "Author": "Dazer", "Description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay.", "UUID": "com.simeo.codex-deck", diff --git a/static/property-inspector/agent.html b/static/property-inspector/agent.html index 7cc2e5e..df5b404 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; } @@ -15,13 +16,38 @@ -

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 is available on macOS only and activates the local app before opening an agent.

diff --git a/test/codex-open.test.ts b/test/codex-open.test.ts index 17aac3e..981b8f1 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 } 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,11 @@ 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.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..df6fe7a 100644 --- a/test/ios-project.test.ts +++ b/test/ios-project.test.ts @@ -207,7 +207,11 @@ 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(inspector, /Focus Codex\*/); + assert.match(inspector, /available on macOS only/); assert.match(plugin, /onDidReceiveGlobalSettings/); + assert.match(plugin, /setFocusCodexOnAgentPress\(event\.settings\.focusCodexOnAgentPress === true\)/); assert.match(render, /data-context-used/); assert.match(settings, /Toggle\([\s\S]*"Context rings"/); assert.match(device, /ContextUsageIndicator/); diff --git a/test/property-inspector.test.ts b/test/property-inspector.test.ts new file mode 100644 index 0000000..9c656bf --- /dev/null +++ b/test/property-inspector.test.ts @@ -0,0 +1,135 @@ +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(/