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 ``` Screenshot 2025-04-16 at 18 12 01 --- 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);