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
```

<img width="377" alt="Screenshot 2025-04-16 at 18 12 01"
src="https://github.com/user-attachments/assets/79aa799b-b0b9-479d-84f1-bfb83d34bfb9"
/>
This commit is contained in:
kchro3
2025-04-17 16:19:26 -07:00
committed by GitHub
parent be7e3fd377
commit 0a2e416b7a
11 changed files with 73 additions and 10 deletions

View File

@@ -176,7 +176,7 @@ The hardening mechanism Codex uses depends on your OS:
| `codex -q "…"` | Noninteractive "quiet mode" | `codex -q --json "explain utils.ts"` | | `codex -q "…"` | Noninteractive "quiet mode" | `codex -q --json "explain utils.ts"` |
| `codex completion <bash\|zsh\|fish>` | Print shell completion script | `codex completion bash` | | `codex completion <bash\|zsh\|fish>` | 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 # ~/.codex/config.yaml
model: o4-mini # Default model model: o4-mini # Default model
fullAutoErrorMode: ask-user # or ignore-and-continue fullAutoErrorMode: ask-user # or ignore-and-continue
notify: true # Enable desktop notifications for responses
``` ```
You can also define custom instructions: You can also define custom instructions:

View File

@@ -68,6 +68,7 @@ const cli = meow(
--no-project-doc Do not automatically include the repository's 'codex.md' --no-project-doc Do not automatically include the repository's 'codex.md'
--project-doc <file> Include an additional markdown file at <file> as context --project-doc <file> Include an additional markdown file at <file> as context
--full-stdout Do not truncate stdout/stderr from command outputs --full-stdout Do not truncate stdout/stderr from command outputs
--notify Enable desktop notifications for responses
Dangerous options Dangerous options
--dangerously-auto-approve-everything --dangerously-auto-approve-everything
@@ -144,6 +145,11 @@ const cli = meow(
"Disable truncation of command stdout/stderr messages (show everything)", "Disable truncation of command stdout/stderr messages (show everything)",
aliases: ["no-truncate"], 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 // Experimental mode where whole directory is loaded in context and model is requested
// to make code edits in a single pass. // to make code edits in a single pass.
@@ -243,6 +249,7 @@ config = {
apiKey, apiKey,
...config, ...config,
model: model ?? config.model, model: model ?? config.model,
notify: Boolean(cli.flags.notify),
}; };
if (!(await isModelSupportedForResponses(config.model))) { if (!(await isModelSupportedForResponses(config.model))) {

View File

@@ -15,7 +15,7 @@ import { formatCommandForDisplay } from "../../format-command.js";
import { useConfirmation } from "../../hooks/use-confirmation.js"; import { useConfirmation } from "../../hooks/use-confirmation.js";
import { useTerminalSize } from "../../hooks/use-terminal-size.js"; import { useTerminalSize } from "../../hooks/use-terminal-size.js";
import { AgentLoop } from "../../utils/agent/agent-loop.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 { ReviewDecision } from "../../utils/agent/review.js";
import { OPENAI_BASE_URL } from "../../utils/config.js"; import { OPENAI_BASE_URL } from "../../utils/config.js";
import { createInputItem } from "../../utils/input-utils.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 HistoryOverlay from "../history-overlay.js";
import ModelOverlay from "../model-overlay.js"; import ModelOverlay from "../model-overlay.js";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import { exec } from "node:child_process";
import OpenAI from "openai"; import OpenAI from "openai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { inspect } from "util"; import { inspect } from "util";
type Props = { type Props = {
@@ -126,6 +127,8 @@ export default function TerminalChat({
additionalWritableRoots, additionalWritableRoots,
fullStdout, fullStdout,
}: Props): React.ReactElement { }: Props): React.ReactElement {
// Desktop notification setting
const notify = config.notify;
const [model, setModel] = useState<string>(config.model); const [model, setModel] = useState<string>(config.model);
const [lastResponseId, setLastResponseId] = useState<string | null>(null); const [lastResponseId, setLastResponseId] = useState<string | null>(null);
const [items, setItems] = useState<Array<ResponseItem>>([]); const [items, setItems] = useState<Array<ResponseItem>>([]);
@@ -284,6 +287,49 @@ export default function TerminalChat({
}; };
}, [loading, confirmationPrompt]); }, [loading, confirmationPrompt]);
// Notify desktop with a preview when an assistant response arrives
const prevLoadingRef = useRef<boolean>(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 // Let's also track whenever the ref becomes available
const agent = agentRef.current; const agent = agentRef.current;
useEffect(() => { useEffect(() => {

View File

@@ -49,6 +49,8 @@ export type StoredConfig = {
approvalMode?: AutoApprovalMode; approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode; fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig; memory?: MemoryConfig;
/** Whether to enable desktop notifications for responses */
notify?: boolean;
history?: { history?: {
maxSize?: number; maxSize?: number;
saveHistory?: boolean; saveHistory?: boolean;
@@ -75,6 +77,8 @@ export type AppConfig = {
instructions: string; instructions: string;
fullAutoErrorMode?: FullAutoErrorMode; fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig; memory?: MemoryConfig;
/** Whether to enable desktop notifications for responses */
notify: boolean;
history?: { history?: {
maxSize: number; maxSize: number;
saveHistory: boolean; saveHistory: boolean;
@@ -263,6 +267,7 @@ export const loadConfig = (
? DEFAULT_FULL_CONTEXT_MODEL ? DEFAULT_FULL_CONTEXT_MODEL
: DEFAULT_AGENTIC_MODEL), : DEFAULT_AGENTIC_MODEL),
instructions: combinedInstructions, instructions: combinedInstructions,
notify: storedConfig.notify === true,
}; };
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -322,6 +327,8 @@ export const loadConfig = (
if (storedConfig.fullAutoErrorMode) { if (storedConfig.fullAutoErrorMode) {
config.fullAutoErrorMode = 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 // Add default history config if not provided
if (storedConfig.history !== undefined) { if (storedConfig.history !== undefined) {

View File

@@ -96,7 +96,7 @@ describe("cancel before first function_call", () => {
onLoading: () => {}, onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any), getCommandConfirmation: async () => ({ review: "yes" } as any),
onLastResponseId: () => {}, onLastResponseId: () => {},
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
}); });
// Start first run. // Start first run.

View File

@@ -104,7 +104,7 @@ describe("cancel clears previous_response_id", () => {
onLoading: () => {}, onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" } as any), getCommandConfirmation: async () => ({ review: "yes" } as any),
onLastResponseId: () => {}, onLastResponseId: () => {},
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
}); });
// First run that triggers a function_call, but we will cancel *before* the // First run that triggers a function_call, but we will cancel *before* the

View File

@@ -95,7 +95,7 @@ describe("Agent cancellation race", () => {
additionalWritableRoots: [], additionalWritableRoots: [],
model: "any", model: "any",
instructions: "", instructions: "",
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any, approvalPolicy: { mode: "auto" } as any,
onItem: (i) => items.push(i), onItem: (i) => items.push(i),
onLoading: () => {}, onLoading: () => {},

View File

@@ -89,7 +89,7 @@ describe("Agent cancellation", () => {
const agent = new AgentLoop({ const agent = new AgentLoop({
model: "any", model: "any",
instructions: "", instructions: "",
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any, approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [], additionalWritableRoots: [],
onItem: (item) => { onItem: (item) => {
@@ -140,7 +140,7 @@ describe("Agent cancellation", () => {
additionalWritableRoots: [], additionalWritableRoots: [],
model: "any", model: "any",
instructions: "", instructions: "",
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any, approvalPolicy: { mode: "auto" } as any,
onItem: (item) => received.push(item), onItem: (item) => received.push(item),
onLoading: () => {}, onLoading: () => {},

View File

@@ -41,6 +41,7 @@ describe("Agent interrupt and continue", () => {
config: { config: {
model: "test-model", model: "test-model",
instructions: "", instructions: "",
notify: false,
}, },
onItem: (item) => received.push(item), onItem: (item) => received.push(item),
onLoading: (loading) => { onLoading: (loading) => {

View File

@@ -111,7 +111,7 @@ describe("Agent terminate (hard cancel)", () => {
const agent = new AgentLoop({ const agent = new AgentLoop({
model: "any", model: "any",
instructions: "", instructions: "",
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any, approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [], additionalWritableRoots: [],
onItem: (item) => received.push(item), onItem: (item) => received.push(item),
@@ -147,7 +147,7 @@ describe("Agent terminate (hard cancel)", () => {
const agent = new AgentLoop({ const agent = new AgentLoop({
model: "any", model: "any",
instructions: "", instructions: "",
config: { model: "any", instructions: "" }, config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any, approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [], additionalWritableRoots: [],
onItem: () => {}, onItem: () => {},

View File

@@ -68,6 +68,7 @@ test("saves and loads config correctly", () => {
const testConfig = { const testConfig = {
model: "test-model", model: "test-model",
instructions: "test instructions", instructions: "test instructions",
notify: false,
}; };
saveConfig(testConfig, testConfigPath, testInstructionsPath); saveConfig(testConfig, testConfigPath, testInstructionsPath);