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:
@@ -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 -q "…"` | Non‑interactive "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:
|
||||||
|
|||||||
@@ -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))) {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user