From 0a2e416b7ae6d36a309de841c369477dffd5eaf4 Mon Sep 17 00:00:00 2001
From: kchro3 <62481661+kchro3@users.noreply.github.com>
Date: Thu, 17 Apr 2025 16:19:26 -0700
Subject: [PATCH] feat: add notifications for MacOS using Applescript (#160)
yolo'ed it with codex. Let me know if this looks good to you.
https://github.com/openai/codex/issues/148
tested with:
```
npm run build:dev
```
---
README.md | 3 +-
codex-cli/src/cli.tsx | 7 +++
.../src/components/chat/terminal-chat.tsx | 50 ++++++++++++++++++-
codex-cli/src/utils/config.ts | 7 +++
codex-cli/tests/agent-cancel-early.test.ts | 2 +-
.../tests/agent-cancel-prev-response.test.ts | 2 +-
codex-cli/tests/agent-cancel-race.test.ts | 2 +-
codex-cli/tests/agent-cancel.test.ts | 4 +-
.../tests/agent-interrupt-continue.test.ts | 1 +
codex-cli/tests/agent-terminate.test.ts | 4 +-
codex-cli/tests/config.test.tsx | 1 +
11 files changed, 73 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index c96f180c..5b4b28e2 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,7 @@ The hardening mechanism Codex uses depends on your OS:
| `codex -q "…"` | Non‑interactive "quiet mode" | `codex -q --json "explain utils.ts"` |
| `codex completion ` | Print shell completion script | `codex completion bash` |
-Key flags: `--model/-m`, `--approval-mode/-a`, and `--quiet/-q`.
+Key flags: `--model/-m`, `--approval-mode/-a`, `--quiet/-q`, and `--notify`.
---
@@ -279,6 +279,7 @@ Codex looks for config files in **`~/.codex/`**.
# ~/.codex/config.yaml
model: o4-mini # Default model
fullAutoErrorMode: ask-user # or ignore-and-continue
+notify: true # Enable desktop notifications for responses
```
You can also define custom instructions:
diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx
index b0bef3aa..8149f65a 100644
--- a/codex-cli/src/cli.tsx
+++ b/codex-cli/src/cli.tsx
@@ -68,6 +68,7 @@ const cli = meow(
--no-project-doc Do not automatically include the repository's 'codex.md'
--project-doc Include an additional markdown file at as context
--full-stdout Do not truncate stdout/stderr from command outputs
+ --notify Enable desktop notifications for responses
Dangerous options
--dangerously-auto-approve-everything
@@ -144,6 +145,11 @@ const cli = meow(
"Disable truncation of command stdout/stderr messages (show everything)",
aliases: ["no-truncate"],
},
+ // Notification
+ notify: {
+ type: "boolean",
+ description: "Enable desktop notifications for responses",
+ },
// Experimental mode where whole directory is loaded in context and model is requested
// to make code edits in a single pass.
@@ -243,6 +249,7 @@ config = {
apiKey,
...config,
model: model ?? config.model,
+ notify: Boolean(cli.flags.notify),
};
if (!(await isModelSupportedForResponses(config.model))) {
diff --git a/codex-cli/src/components/chat/terminal-chat.tsx b/codex-cli/src/components/chat/terminal-chat.tsx
index e209c61a..7885f1f6 100644
--- a/codex-cli/src/components/chat/terminal-chat.tsx
+++ b/codex-cli/src/components/chat/terminal-chat.tsx
@@ -15,7 +15,7 @@ import { formatCommandForDisplay } from "../../format-command.js";
import { useConfirmation } from "../../hooks/use-confirmation.js";
import { useTerminalSize } from "../../hooks/use-terminal-size.js";
import { AgentLoop } from "../../utils/agent/agent-loop.js";
-import { log, isLoggingEnabled } from "../../utils/agent/log.js";
+import { isLoggingEnabled, log } from "../../utils/agent/log.js";
import { ReviewDecision } from "../../utils/agent/review.js";
import { OPENAI_BASE_URL } from "../../utils/config.js";
import { createInputItem } from "../../utils/input-utils.js";
@@ -28,8 +28,9 @@ import HelpOverlay from "../help-overlay.js";
import HistoryOverlay from "../history-overlay.js";
import ModelOverlay from "../model-overlay.js";
import { Box, Text } from "ink";
+import { exec } from "node:child_process";
import OpenAI from "openai";
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { inspect } from "util";
type Props = {
@@ -126,6 +127,8 @@ export default function TerminalChat({
additionalWritableRoots,
fullStdout,
}: Props): React.ReactElement {
+ // Desktop notification setting
+ const notify = config.notify;
const [model, setModel] = useState(config.model);
const [lastResponseId, setLastResponseId] = useState(null);
const [items, setItems] = useState>([]);
@@ -284,6 +287,49 @@ export default function TerminalChat({
};
}, [loading, confirmationPrompt]);
+ // Notify desktop with a preview when an assistant response arrives
+ const prevLoadingRef = useRef(false);
+ useEffect(() => {
+ // Only notify when notifications are enabled
+ if (!notify) {
+ prevLoadingRef.current = loading;
+ return;
+ }
+ if (
+ prevLoadingRef.current &&
+ !loading &&
+ confirmationPrompt == null &&
+ items.length > 0
+ ) {
+ if (process.platform === "darwin") {
+ // find the last assistant message
+ const assistantMessages = items.filter(
+ (i) => i.type === "message" && i.role === "assistant",
+ );
+ const last = assistantMessages[assistantMessages.length - 1];
+ if (last) {
+ const text = last.content
+ .map((c) => {
+ if (c.type === "output_text") {
+ return c.text;
+ }
+ return "";
+ })
+ .join("")
+ .trim();
+ const preview = text.replace(/\n/g, " ").slice(0, 100);
+ const safePreview = preview.replace(/"/g, '\\"');
+ const title = "Codex CLI";
+ const cwd = PWD;
+ exec(
+ `osascript -e 'display notification "${safePreview}" with title "${title}" subtitle "${cwd}" sound name "Ping"'`,
+ );
+ }
+ }
+ }
+ prevLoadingRef.current = loading;
+ }, [notify, loading, confirmationPrompt, items, PWD]);
+
// Let's also track whenever the ref becomes available
const agent = agentRef.current;
useEffect(() => {
diff --git a/codex-cli/src/utils/config.ts b/codex-cli/src/utils/config.ts
index e3536f9e..309256e9 100644
--- a/codex-cli/src/utils/config.ts
+++ b/codex-cli/src/utils/config.ts
@@ -49,6 +49,8 @@ export type StoredConfig = {
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
+ /** Whether to enable desktop notifications for responses */
+ notify?: boolean;
history?: {
maxSize?: number;
saveHistory?: boolean;
@@ -75,6 +77,8 @@ export type AppConfig = {
instructions: string;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
+ /** Whether to enable desktop notifications for responses */
+ notify: boolean;
history?: {
maxSize: number;
saveHistory: boolean;
@@ -263,6 +267,7 @@ export const loadConfig = (
? DEFAULT_FULL_CONTEXT_MODEL
: DEFAULT_AGENTIC_MODEL),
instructions: combinedInstructions,
+ notify: storedConfig.notify === true,
};
// -----------------------------------------------------------------------
@@ -322,6 +327,8 @@ export const loadConfig = (
if (storedConfig.fullAutoErrorMode) {
config.fullAutoErrorMode = storedConfig.fullAutoErrorMode;
}
+ // Notification setting: enable desktop notifications when set in config
+ config.notify = storedConfig.notify === true;
// Add default history config if not provided
if (storedConfig.history !== undefined) {
diff --git a/codex-cli/tests/agent-cancel-early.test.ts b/codex-cli/tests/agent-cancel-early.test.ts
index 1460bb0b..47263f22 100644
--- a/codex-cli/tests/agent-cancel-early.test.ts
+++ b/codex-cli/tests/agent-cancel-early.test.ts
@@ -96,7 +96,7 @@ describe("cancel before first function_call", () => {
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),
onLastResponseId: () => {},
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
});
// Start first run.
diff --git a/codex-cli/tests/agent-cancel-prev-response.test.ts b/codex-cli/tests/agent-cancel-prev-response.test.ts
index fbeff0a7..b6818f18 100644
--- a/codex-cli/tests/agent-cancel-prev-response.test.ts
+++ b/codex-cli/tests/agent-cancel-prev-response.test.ts
@@ -104,7 +104,7 @@ describe("cancel clears previous_response_id", () => {
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any),
onLastResponseId: () => {},
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
});
// First run that triggers a function_call, but we will cancel *before* the
diff --git a/codex-cli/tests/agent-cancel-race.test.ts b/codex-cli/tests/agent-cancel-race.test.ts
index 5ae572d1..60ed1ea4 100644
--- a/codex-cli/tests/agent-cancel-race.test.ts
+++ b/codex-cli/tests/agent-cancel-race.test.ts
@@ -95,7 +95,7 @@ describe("Agent cancellation race", () => {
additionalWritableRoots: [],
model: "any",
instructions: "",
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any,
onItem: (i) => items.push(i),
onLoading: () => {},
diff --git a/codex-cli/tests/agent-cancel.test.ts b/codex-cli/tests/agent-cancel.test.ts
index 2cd01cd6..cf154f7a 100644
--- a/codex-cli/tests/agent-cancel.test.ts
+++ b/codex-cli/tests/agent-cancel.test.ts
@@ -89,7 +89,7 @@ describe("Agent cancellation", () => {
const agent = new AgentLoop({
model: "any",
instructions: "",
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (item) => {
@@ -140,7 +140,7 @@ describe("Agent cancellation", () => {
additionalWritableRoots: [],
model: "any",
instructions: "",
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any,
onItem: (item) => received.push(item),
onLoading: () => {},
diff --git a/codex-cli/tests/agent-interrupt-continue.test.ts b/codex-cli/tests/agent-interrupt-continue.test.ts
index db4006bc..d41d2541 100644
--- a/codex-cli/tests/agent-interrupt-continue.test.ts
+++ b/codex-cli/tests/agent-interrupt-continue.test.ts
@@ -41,6 +41,7 @@ describe("Agent interrupt and continue", () => {
config: {
model: "test-model",
instructions: "",
+ notify: false,
},
onItem: (item) => received.push(item),
onLoading: (loading) => {
diff --git a/codex-cli/tests/agent-terminate.test.ts b/codex-cli/tests/agent-terminate.test.ts
index 634245bd..ff68964d 100644
--- a/codex-cli/tests/agent-terminate.test.ts
+++ b/codex-cli/tests/agent-terminate.test.ts
@@ -111,7 +111,7 @@ describe("Agent terminate (hard cancel)", () => {
const agent = new AgentLoop({
model: "any",
instructions: "",
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (item) => received.push(item),
@@ -147,7 +147,7 @@ describe("Agent terminate (hard cancel)", () => {
const agent = new AgentLoop({
model: "any",
instructions: "",
- config: { model: "any", instructions: "" },
+ config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: () => {},
diff --git a/codex-cli/tests/config.test.tsx b/codex-cli/tests/config.test.tsx
index dfe80e30..024db853 100644
--- a/codex-cli/tests/config.test.tsx
+++ b/codex-cli/tests/config.test.tsx
@@ -68,6 +68,7 @@ test("saves and loads config correctly", () => {
const testConfig = {
model: "test-model",
instructions: "test instructions",
+ notify: false,
};
saveConfig(testConfig, testConfigPath, testInstructionsPath);