diff --git a/codex-rs/core/docs.md b/codex-rs/core/docs.md index 009b360a7..542222ac6 100644 --- a/codex-rs/core/docs.md +++ b/codex-rs/core/docs.md @@ -115,6 +115,24 @@ The `rollout/` module handles session persistence: - `list.rs`: Lists and queries saved sessions - Sessions stored in `~/.codex/sessions/` with JSON-lines format +**User Notifications (`user_notification.rs`):** + +The `UserNotifier` and `UserNotification` types enable external notification handlers to receive events from Codex: + +- `UserNotifier::new(notify: Option>)` - Creates a notifier with an optional external command +- `UserNotifier::notify(¬ification)` - Serializes the notification to JSON and invokes the configured command +- `UserNotification::AgentTurnComplete` - Sent when an agent turn finishes +- `UserNotification::ApprovalRequested` - Sent when user approval is needed (exec, edit, or elicitation) + +The `ApprovalRequested` variant includes: +- `request_type`: "exec", "edit", or "elicitation" +- `message`: Human-readable description +- `command`: For exec requests, the command being requested +- `file_paths`: For edit requests, the files being modified +- `idle_duration_secs`: Present when idle threshold was exceeded before the request + +Both types are publicly exported from `lib.rs` for TUI consumption. + **MCP Integration:** The `mcp/` and `mcp_connection_manager.rs` modules manage MCP server connections defined in config. diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index b57eca6fe..8f02de130 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -15,6 +15,7 @@ use toml_edit::Table as TomlTable; use toml_edit::value; // Re-export for users of ConfigEditsBuilder::set_path +pub use toml_edit::Array; pub use toml_edit::Item; pub use toml_edit::value as toml_value; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7a9440eb2..7be9627e5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -92,6 +92,9 @@ mod state; mod tasks; mod user_notification; mod user_shell_command; + +pub use user_notification::UserNotification; +pub use user_notification::UserNotifier; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; diff --git a/codex-rs/core/src/user_notification.rs b/codex-rs/core/src/user_notification.rs index 7bbd1d956..9071cfafa 100644 --- a/codex-rs/core/src/user_notification.rs +++ b/codex-rs/core/src/user_notification.rs @@ -3,12 +3,12 @@ use tracing::error; use tracing::warn; #[derive(Debug, Default)] -pub(crate) struct UserNotifier { +pub struct UserNotifier { notify_command: Option>, } impl UserNotifier { - pub(crate) fn notify(&self, notification: &UserNotification) { + pub fn notify(&self, notification: &UserNotification) { if let Some(notify_command) = &self.notify_command && !notify_command.is_empty() { @@ -34,7 +34,7 @@ impl UserNotifier { } } - pub(crate) fn new(notify: Option>) -> Self { + pub fn new(notify: Option>) -> Self { Self { notify_command: notify, } @@ -46,7 +46,7 @@ impl UserNotifier { /// program. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "kebab-case")] -pub(crate) enum UserNotification { +pub enum UserNotification { #[serde(rename_all = "kebab-case")] AgentTurnComplete { thread_id: String, @@ -59,6 +59,31 @@ pub(crate) enum UserNotification { /// The last message sent by the assistant in the turn. last_assistant_message: Option, }, + + /// Notification sent when an approval request arrives, optionally after + /// an idle period. This allows external notification handlers to alert + /// the user when the agent needs their attention. + #[serde(rename_all = "kebab-case")] + ApprovalRequested { + /// The type of approval request: "exec", "edit", or "elicitation" + request_type: String, + + /// Human-readable message describing the approval request + message: String, + + /// For exec requests, the command being requested + #[serde(skip_serializing_if = "Option::is_none")] + command: Option, + + /// For edit requests, the list of file paths being modified + #[serde(skip_serializing_if = "Option::is_none")] + file_paths: Option>, + + /// How long the system was idle before this request, in seconds. + /// Present when the idle threshold was exceeded. + #[serde(skip_serializing_if = "Option::is_none")] + idle_duration_secs: Option, + }, } #[cfg(test)] @@ -84,4 +109,41 @@ mod tests { ); Ok(()) } + + #[test] + fn test_approval_requested_exec_notification() -> Result<()> { + let notification = UserNotification::ApprovalRequested { + request_type: "exec".to_string(), + message: "Approval needed for command execution".to_string(), + command: Some("rm -rf /tmp/test".to_string()), + file_paths: None, + idle_duration_secs: Some(5), + }; + let serialized = serde_json::to_string(¬ification)?; + assert!(serialized.contains(r#""type":"approval-requested""#)); + assert!(serialized.contains(r#""request-type":"exec""#)); + assert!(serialized.contains(r#""command":"rm -rf /tmp/test""#)); + assert!(serialized.contains(r#""idle-duration-secs":5"#)); + Ok(()) + } + + #[test] + fn test_approval_requested_edit_notification() -> Result<()> { + let notification = UserNotification::ApprovalRequested { + request_type: "edit".to_string(), + message: "Approval needed for file edits".to_string(), + command: None, + file_paths: Some(vec![ + "/path/to/file1.rs".to_string(), + "/path/to/file2.rs".to_string(), + ]), + idle_duration_secs: None, + }; + let serialized = serde_json::to_string(¬ification)?; + assert!(serialized.contains(r#""type":"approval-requested""#)); + assert!(serialized.contains(r#""request-type":"edit""#)); + assert!(serialized.contains(r#""file-paths""#)); + assert!(serialized.contains(r#"/path/to/file1.rs"#)); + Ok(()) + } } diff --git a/codex-rs/tui/docs.md b/codex-rs/tui/docs.md index f703e373a..9be722299 100644 --- a/codex-rs/tui/docs.md +++ b/codex-rs/tui/docs.md @@ -362,6 +362,37 @@ Most event types (exec begin/end, MCP calls, elicitation) are queued during acti - The `InterruptManager` still contains `ExecApproval` and `ApplyPatchApproval` variants for completeness, but these methods are marked `#[allow(dead_code)]` - `on_task_complete()` calls `flush_interrupt_queue()` for any remaining queued items +**OS-Level Approval Notifications:** + +The TUI supports OS-level notifications when approval is needed, implemented via `UserNotifier` from `codex-core` and `IdleDetector`: + +``` +┌─────────────────────┐ check_idle() ┌─────────────────┐ +│ IdleDetector │◄───────────────────│ │ +│ (5-second threshold) │ ChatWidget │ +└─────────────────────┘ │ │ + │ - user_notifier│ +┌─────────────────────┐ notify() │ - idle_detector│ +│ UserNotifier │◄───────────────────│ │ +│ (spawns notify │ └─────────────────┘ +│ hook command) │ ▲ +└─────────────────────┘ │ + │ │ + ▼ record_activity() +┌─────────────────────┐ (on user messages, +│ ~/.nori/cli/ │ agent starts work) +│ notify-hook.sh │ +└─────────────────────┘ +``` + +- `IdleDetector` (`idle_detector.rs`): Tracks idle state using a 5-second threshold. `check_idle()` returns the idle duration once per idle period (prevents duplicate notifications). `record_activity()` resets the timer. +- `ChatWidget` fields: `user_notifier: UserNotifier` and `idle_detector: IdleDetector` +- `send_approval_notification()`: Creates `UserNotification::ApprovalRequested` with idle duration if threshold exceeded +- Activity is recorded when user submits messages (`submit_user_message`) and when agent starts (`on_task_started`) +- Approval handlers call `send_approval_notification()`: `handle_exec_approval_now()`, `handle_apply_patch_approval_now()`, `handle_elicitation_request_now()` + +The notify hook is deployed to `~/.nori/cli/notify-hook.sh` during first-launch onboarding and configured in `config.toml` via `notify = ["/path/to/notify-hook.sh"]`. See `@/codex-rs/tui/src/nori/docs.md` for deployment details. + **Approval Overlay Model Display Name:** The approval overlay displays the current agent's display name (e.g., "Claude", "Gemini") instead of a hardcoded name in options like "No, and tell Claude what to do differently". The display name flows through: diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 63f4fe8fc..75e7beec5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10,6 +10,8 @@ use std::time::Duration; use codex_app_server_protocol::AuthMode; #[cfg(feature = "backend-client")] use codex_backend_client::Client as BackendClient; +use codex_core::UserNotification; +use codex_core::UserNotifier; use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; @@ -100,6 +102,7 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; +use crate::idle_detector::IdleDetector; use crate::login_handler::AgentLoginSupport; use crate::login_handler::LoginHandler; #[allow(unused_imports)] @@ -361,6 +364,10 @@ pub(crate) struct ChatWidget { session_stats: SessionStats, // Login handler for /login command login_handler: Option, + // OS-level notification handler for approval events + user_notifier: UserNotifier, + // Idle detection for approval notifications + idle_detector: IdleDetector, } /// Information about a pending agent switch in ChatWidget. @@ -482,6 +489,8 @@ impl ChatWidget { } fn on_agent_message(&mut self, message: String) { + // Reset idle timer when agent produces output + self.record_activity(); // Track assistant message for session statistics self.session_stats.record_assistant_message(); @@ -1210,7 +1219,16 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); let command = shlex::try_join(ev.command.iter().map(String::as_str)) .unwrap_or_else(|_| ev.command.join(" ")); - self.notify(Notification::ExecApprovalRequested { command }); + self.notify(Notification::ExecApprovalRequested { + command: command.clone(), + }); + // Send OS-level notification via notify hook + self.send_approval_notification( + "exec", + format!("Approval requested: {}", truncate_text(&command, 50)), + Some(command), + None, + ); let request = ApprovalRequest::Exec { id, @@ -1229,18 +1247,40 @@ impl ChatWidget { ) { self.flush_answer_stream_with_separator(); + let file_paths: Vec = ev.changes.keys().cloned().collect(); let request = ApprovalRequest::ApplyPatch { id, reason: ev.reason, - changes: ev.changes.clone(), + changes: ev.changes, cwd: self.config.cwd.clone(), }; self.bottom_pane.push_approval_request(request); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.clone(), - changes: ev.changes.keys().cloned().collect(), + changes: file_paths.clone(), }); + // Send OS-level notification via notify hook + let file_count = file_paths.len(); + let message = if file_count == 1 { + format!( + "Nori wants to edit {}", + display_path_for(&file_paths[0], &self.config.cwd) + ) + } else { + format!("Nori wants to edit {file_count} files") + }; + self.send_approval_notification( + "edit", + message, + None, + Some( + file_paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(), + ), + ); } pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { @@ -1249,6 +1289,13 @@ impl ChatWidget { self.notify(Notification::ElicitationRequested { server_name: ev.server_name.clone(), }); + // Send OS-level notification via notify hook + self.send_approval_notification( + "elicitation", + format!("Approval requested by {}", ev.server_name), + None, + None, + ); let request = ApprovalRequest::McpElicitation { server_name: ev.server_name, @@ -1260,6 +1307,8 @@ impl ChatWidget { } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { + // Reset idle timer when agent starts work + self.record_activity(); // Track Bash tool call for session statistics self.session_stats.record_tool_call("Bash"); @@ -1499,6 +1548,8 @@ impl ChatWidget { acp_handle: spawn_result.acp_handle, session_stats: SessionStats::new(), login_handler: None, + user_notifier: UserNotifier::new(config.notify.clone()), + idle_detector: IdleDetector::new(Duration::from_secs(5)), }; widget.prefetch_rate_limits(); @@ -1593,6 +1644,8 @@ impl ChatWidget { acp_handle: None, session_stats: SessionStats::new(), login_handler: None, + user_notifier: UserNotifier::new(config.notify.clone()), + idle_detector: IdleDetector::new(Duration::from_secs(5)), }; widget.prefetch_rate_limits(); @@ -1938,6 +1991,9 @@ impl ChatWidget { return; } + // Reset idle timer when user sends a message + self.record_activity(); + // Special-case: "/login " triggers login for a specific agent // This intercepts before the message is sent to the agent if let Some(agent_name) = text.strip_prefix("/login ").map(str::trim) @@ -2256,6 +2312,34 @@ impl ChatWidget { self.request_redraw(); } + /// Send an OS-level notification for an approval request. + /// This triggers the external notify hook if configured. + fn send_approval_notification( + &mut self, + request_type: &str, + message: String, + command: Option, + file_paths: Option>, + ) { + // Check idle duration and include it if threshold was exceeded + let idle_duration = self.idle_detector.check_idle(); + let idle_duration_secs = idle_duration.map(|d| d.as_secs()); + + let notification = UserNotification::ApprovalRequested { + request_type: request_type.to_string(), + message, + command, + file_paths, + idle_duration_secs, + }; + self.user_notifier.notify(¬ification); + } + + /// Record user/agent activity to reset the idle timer. + fn record_activity(&mut self) { + self.idle_detector.record_activity(); + } + pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { if let Some(notif) = self.pending_notification.take() { tui.notify(notif.display()); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1ce3a9724..151f04048 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -391,6 +391,8 @@ pub(crate) fn make_chatwidget_manual() -> ( acp_handle: None, session_stats: crate::session_stats::SessionStats::new(), login_handler: None, + user_notifier: UserNotifier::new(cfg.notify.clone()), + idle_detector: IdleDetector::new(Duration::from_secs(5)), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/idle_detector.rs b/codex-rs/tui/src/idle_detector.rs new file mode 100644 index 000000000..a0d991fe4 --- /dev/null +++ b/codex-rs/tui/src/idle_detector.rs @@ -0,0 +1,129 @@ +//! Idle detection for ACP backend notifications. +//! +//! Tracks the last activity timestamp and determines when the system +//! has been idle long enough to warrant a notification. + +use std::time::Duration; +use std::time::Instant; + +/// Tracks idle state for notification purposes. +/// +/// The detector tracks the last activity time and can determine if +/// the system has been idle for longer than a configured threshold. +/// It also ensures that only one notification is sent per idle period. +pub struct IdleDetector { + last_activity: Instant, + threshold: Duration, + notified_for_current_idle: bool, +} + +impl IdleDetector { + /// Create a new idle detector with the given threshold. + /// + /// The threshold determines how long the system must be idle + /// before `check_idle` returns a duration. + pub fn new(threshold: Duration) -> Self { + Self { + last_activity: Instant::now(), + threshold, + notified_for_current_idle: false, + } + } + + /// Record that activity occurred, resetting the idle timer. + /// + /// This should be called whenever an event is received from + /// the ACP backend (e.g., agent message, tool execution, etc.) + pub fn record_activity(&mut self) { + self.last_activity = Instant::now(); + self.notified_for_current_idle = false; + } + + /// Check if the system has been idle for longer than the threshold. + /// + /// Returns `Some(duration)` if: + /// - The system has been idle for longer than the threshold + /// - A notification has not already been sent for this idle period + /// + /// Returns `None` if: + /// - The system is not idle (activity occurred recently) + /// - A notification was already sent for this idle period + /// + /// Calling this method when it returns `Some` will mark the idle + /// period as notified, so subsequent calls will return `None` + /// until `record_activity` is called. + pub fn check_idle(&mut self) -> Option { + if self.notified_for_current_idle { + return None; + } + + let elapsed = self.last_activity.elapsed(); + if elapsed >= self.threshold { + self.notified_for_current_idle = true; + Some(elapsed) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn new_detector_is_not_idle() { + let mut detector = IdleDetector::new(Duration::from_millis(100)); + // Immediately after creation, should not be idle + assert!(detector.check_idle().is_none()); + } + + #[test] + fn detector_becomes_idle_after_threshold() { + let mut detector = IdleDetector::new(Duration::from_millis(50)); + + // Wait for threshold to pass + thread::sleep(Duration::from_millis(60)); + + // Should now be idle + let idle_duration = detector.check_idle(); + assert!(idle_duration.is_some()); + assert!(idle_duration.unwrap() >= Duration::from_millis(50)); + } + + #[test] + fn check_idle_only_returns_once_per_idle_period() { + let mut detector = IdleDetector::new(Duration::from_millis(50)); + + thread::sleep(Duration::from_millis(60)); + + // First check returns Some + assert!(detector.check_idle().is_some()); + + // Second check returns None (already notified) + assert!(detector.check_idle().is_none()); + } + + #[test] + fn record_activity_resets_idle_state() { + let mut detector = IdleDetector::new(Duration::from_millis(50)); + + thread::sleep(Duration::from_millis(60)); + + // Consume the idle notification + assert!(detector.check_idle().is_some()); + + // Record activity + detector.record_activity(); + + // Should not be idle anymore + assert!(detector.check_idle().is_none()); + + // Wait for threshold again + thread::sleep(Duration::from_millis(60)); + + // Should be idle again + assert!(detector.check_idle().is_some()); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9d8266167..96dc5245c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -61,6 +61,7 @@ mod file_search; mod frames; mod get_git_diff; mod history_cell; +mod idle_detector; pub mod insert_history; mod key_hint; pub mod live_wrap; diff --git a/codex-rs/tui/src/nori/docs.md b/codex-rs/tui/src/nori/docs.md index 85fc5ab50..9a5fc487e 100644 --- a/codex-rs/tui/src/nori/docs.md +++ b/codex-rs/tui/src/nori/docs.md @@ -132,4 +132,27 @@ Provides Nori-branded first-launch onboarding flow: - `trust_directory.rs`: Directory trust prompt - `onboarding_screen.rs`: Orchestrates the multi-step onboarding flow +**Notify Hook Deployment (`first_launch.rs`):** + +On first launch, the onboarding flow deploys a bundled notification script and configures it: + +1. `deploy_notify_hook(nori_home)`: Writes the bundled `notify-hook.sh` to `~/.nori/cli/notify-hook.sh` + - Script content is embedded at compile time via `include_str!("../notify-hook.sh")` + - Sets executable permissions on Unix systems (`chmod +x`) +2. `mark_first_launch_complete(nori_home)`: Deploys the hook AND configures it in `config.toml` + - Sets `cli.first_launch_complete = true` + - Sets `notify = ["/path/to/notify-hook.sh"]` using `ConfigEditsBuilder::set_path()` + +**Bundled Notification Script (`notify-hook.sh`):** + +Cross-platform OS notification script that receives JSON as a CLI argument: + +| Platform | Tool | Fallback | +|----------|------|----------| +| Linux | `notify-send` | None (logs warning) | +| macOS | `terminal-notifier` | `osascript` | +| Windows | PowerShell `NotifyIcon` | None | + +JSON parsing attempts `python3` first, then `jq`, with a fallback message. For `approval-requested` notifications, displays the message with optional idle duration. Supports `NORI_NOTIFY_DEBUG` env var for logging to `/tmp/nori-notify.log`. + Created and maintained by Nori. diff --git a/codex-rs/tui/src/nori/notify-hook.sh b/codex-rs/tui/src/nori/notify-hook.sh new file mode 100644 index 000000000..dbb0b3d86 --- /dev/null +++ b/codex-rs/tui/src/nori/notify-hook.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# Nori CLI Notification Hook +# This script receives notification JSON as a CLI argument and displays OS-level notifications. +# Supports Linux (notify-send), macOS (osascript), and Windows (PowerShell). + +set -e + +# Configuration +readonly NOTIFICATION_TITLE="Nori" +readonly LOG_FILE="${NORI_NOTIFY_LOG:-/tmp/nori-notify.log}" + +# Logging function +log() { + if [[ -n "$NORI_NOTIFY_DEBUG" ]]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" + fi +} + +# Get JSON from CLI argument +NOTIFICATION_JSON="${1:-}" +if [[ -z "$NOTIFICATION_JSON" ]]; then + log "ERROR: No notification JSON provided" + exit 0 # Exit gracefully to avoid blocking the CLI +fi + +log "Received notification: $NOTIFICATION_JSON" + +# Parse message from JSON using available tools +parse_message() { + local json="$1" + local message="" + + # Try python3 first (most reliable) + if command -v python3 >/dev/null 2>&1; then + message=$(echo "$json" | python3 -c " +import sys, json +try: + data = json.loads(sys.stdin.read()) + msg_type = data.get('type', '') + if msg_type == 'approval-requested': + req_type = data.get('request-type', 'action') + msg = data.get('message', 'Approval needed') + idle = data.get('idle-duration-secs') + if idle: + print(f'{msg} (idle {idle}s)') + else: + print(msg) + elif msg_type == 'agent-turn-complete': + print(data.get('last-assistant-message', 'Agent completed')[:100]) + else: + print(data.get('message', 'Notification')) +except Exception as e: + print('Nori needs your attention') +" 2>/dev/null) + fi + + # Try jq if python3 failed + if [[ -z "$message" ]] && command -v jq >/dev/null 2>&1; then + message=$(echo "$json" | jq -r '.message // "Nori needs your attention"' 2>/dev/null) + fi + + # Fallback + if [[ -z "$message" ]]; then + message="Nori needs your attention" + fi + + echo "$message" +} + +MESSAGE=$(parse_message "$NOTIFICATION_JSON") +log "Parsed message: $MESSAGE" + +# Detect OS +OS_TYPE=$(uname -s) + +# Send notification based on OS +case "$OS_TYPE" in + Linux*) + if command -v notify-send >/dev/null 2>&1; then + notify-send "$NOTIFICATION_TITLE" "$MESSAGE" --icon=dialog-information --urgency=normal 2>/dev/null || true + log "Sent Linux notification via notify-send" + else + log "WARNING: notify-send not found. Install libnotify-bin for notifications." + fi + ;; + Darwin*) + # Escape message for AppleScript (escape backslashes and quotes) + ESCAPED_MESSAGE=$(printf '%s' "$MESSAGE" | sed 's/\\/\\\\/g; s/"/\\"/g') + # Try terminal-notifier first (better click-to-focus support) + if command -v terminal-notifier >/dev/null 2>&1; then + terminal-notifier -title "$NOTIFICATION_TITLE" -message "$ESCAPED_MESSAGE" -sound default 2>/dev/null || true + log "Sent macOS notification via terminal-notifier" + else + # Fallback to osascript + osascript -e "display notification \"$ESCAPED_MESSAGE\" with title \"$NOTIFICATION_TITLE\"" 2>/dev/null || true + log "Sent macOS notification via osascript" + fi + ;; + MINGW*|MSYS*|CYGWIN*) + # Windows via PowerShell + if command -v powershell.exe >/dev/null 2>&1; then + # Escape message for PowerShell (escape single quotes by doubling) + PS_MESSAGE=$(printf '%s' "$MESSAGE" | sed "s/'/''/g") + powershell.exe -Command " + Add-Type -AssemblyName System.Windows.Forms + \$notify = New-Object System.Windows.Forms.NotifyIcon + \$notify.Icon = [System.Drawing.SystemIcons]::Information + \$notify.Visible = \$true + \$notify.ShowBalloonTip(5000, '$NOTIFICATION_TITLE', '$PS_MESSAGE', 'Info') + Start-Sleep -Seconds 1 + \$notify.Dispose() + " 2>/dev/null || true + log "Sent Windows notification via PowerShell" + fi + ;; + *) + log "WARNING: Unsupported OS: $OS_TYPE" + ;; +esac + +log "Notification hook completed" +exit 0 diff --git a/codex-rs/tui/src/nori/onboarding/first_launch.rs b/codex-rs/tui/src/nori/onboarding/first_launch.rs index aa8b6434d..6b6c85b43 100644 --- a/codex-rs/tui/src/nori/onboarding/first_launch.rs +++ b/codex-rs/tui/src/nori/onboarding/first_launch.rs @@ -9,9 +9,15 @@ use std::io; use std::path::Path; +use codex_core::config::edit::Array as TomlArray; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::edit::Item as TomlItem; use codex_core::config::edit::toml_value; +/// The bundled notify-hook.sh script content. +/// This script is deployed to ~/.nori/cli/notify-hook.sh on first launch. +const NOTIFY_HOOK_SCRIPT: &str = include_str!("../notify-hook.sh"); + /// Check if this is the user's first launch of Nori. /// /// Returns `true` if `config.toml` does not exist in the nori_home directory. @@ -20,14 +26,56 @@ pub(crate) fn is_first_launch(nori_home: &Path) -> bool { !nori_home.join("config.toml").exists() } +/// Deploy the bundled notify-hook.sh script to the nori_home directory. +/// +/// This creates `~/.nori/cli/notify-hook.sh` with the bundled script content +/// and sets executable permissions on Unix systems. +/// +/// Note: nori_home is expected to be `~/.nori/cli` (the full path). +pub(crate) fn deploy_notify_hook(nori_home: &Path) -> io::Result<()> { + let hook_path = nori_home.join("notify-hook.sh"); + + // Write the script content + std::fs::write(&hook_path, NOTIFY_HOOK_SCRIPT)?; + + // Set executable permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&hook_path)?.permissions(); + perms.set_mode(perms.mode() | 0o111); // Add execute bits + std::fs::set_permissions(&hook_path, perms)?; + } + + Ok(()) +} + /// Mark the first-launch onboarding as complete. /// -/// Sets `cli.first_launch_complete = true` in the config.toml file. +/// This function: +/// 1. Deploys the notify-hook.sh script to nori_home +/// 2. Sets `cli.first_launch_complete = true` in the config.toml file +/// 3. Configures `notify` to point to the deployed hook script +/// /// Uses ConfigEditsBuilder to merge with existing config instead of overwriting. /// Note: nori_home is expected to be `~/.nori/cli` (the full path). pub(crate) fn mark_first_launch_complete(nori_home: &Path) -> io::Result<()> { + // Deploy the notify hook script + deploy_notify_hook(nori_home)?; + + // Get the path to the notify hook for config + let hook_path = nori_home.join("notify-hook.sh"); + let hook_path_str = hook_path.to_string_lossy().to_string(); + + // Create a TOML array for the notify setting + let mut notify_array = TomlArray::new(); + notify_array.push(hook_path_str); + let notify_value = TomlItem::Value(notify_array.into()); + + // Update config with first_launch_complete and notify settings ConfigEditsBuilder::new(nori_home) .set_path(&["cli", "first_launch_complete"], toml_value(true)) + .set_path(&["notify"], notify_value) .apply_blocking() .map_err(io::Error::other) } @@ -75,4 +123,58 @@ mod tests { assert!(!is_first_launch(temp.path())); } + + #[test] + fn deploy_notify_hook_creates_script_file() { + let temp = TempDir::new().expect("create temp dir"); + + deploy_notify_hook(temp.path()).expect("deploy hook"); + + let hook_path = temp.path().join("notify-hook.sh"); + assert!(hook_path.exists(), "notify-hook.sh should be created"); + + let content = std::fs::read_to_string(&hook_path).expect("read hook"); + assert!(content.contains("#!/bin/bash"), "should have bash shebang"); + assert!( + content.contains("notify-send") || content.contains("osascript"), + "should contain OS notification commands" + ); + } + + #[test] + fn deploy_notify_hook_sets_executable_permission() { + let temp = TempDir::new().expect("create temp dir"); + + deploy_notify_hook(temp.path()).expect("deploy hook"); + + let hook_path = temp.path().join("notify-hook.sh"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&hook_path).expect("get metadata"); + let mode = metadata.permissions().mode(); + assert!(mode & 0o111 != 0, "should be executable"); + } + } + + #[test] + fn mark_first_launch_complete_configures_notify_hook() { + let temp = TempDir::new().expect("create temp dir"); + + mark_first_launch_complete(temp.path()).expect("mark complete"); + + let config_path = temp.path().join("config.toml"); + let content = std::fs::read_to_string(config_path).expect("read config"); + + // The notify setting should point to the deployed hook + assert!( + content.contains("notify"), + "config should contain notify setting" + ); + assert!( + content.contains("notify-hook.sh"), + "notify should reference notify-hook.sh" + ); + } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..4d71a8a94 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "nori-cli-monorepo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nori-cli-monorepo", + "devDependencies": { + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=22", + "pnpm": ">=9.0.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +}