From 04581f96044a9fe6062593c4d557036477aee961 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 16 Sep 2026 03:23:02 +0000 Subject: [PATCH] Add `/daemon` menu for local background server updates (#45854) ## Why Local daemon version warnings direct users to a shell command. Provide an update flow from the TUI with an explicit choice of package source and confirmation before exiting. ## What changed - Add `/daemon` with options to install the latest public stable release or use the current CLI package. Point local server version notices to the menu. - Default confirmation to Cancel and explain restart, interruption, and relaunch behavior. After confirmation, exit the TUI and run the update through the launching CLI executable, propagating failures. - Keep maintenance available when disconnected or using the embedded server. Disable updates for remote connections or a missing CLI executable, and disable copying the CLI build when no local package is available. ## Testing Add menu snapshots and interaction tests covering both update sources, cancellation, and unavailable actions. Add a Unix CLI handoff test verifying the selected executable, command arguments, and failure propagation. GitOrigin-RevId: 06b485feb5650673ec7dd00adda01fedb8909393 --- codex-rs/cli/src/daemon_update_tests.rs | 37 ++++ codex-rs/cli/src/main.rs | 54 ++++-- codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app/daemon_menu.rs | 132 ++++++++++++++ codex-rs/tui/src/app/daemon_menu_tests.rs | 168 ++++++++++++++++++ codex-rs/tui/src/app/event_dispatch.rs | 11 +- codex-rs/tui/src/app/server_version_notice.rs | 13 +- ..._menu__tests__daemon_cli_confirmation.snap | 15 ++ ...emon_menu__tests__daemon_disconnected.snap | 11 ++ ..._app__daemon_menu__tests__daemon_menu.snap | 11 ++ ...on_menu__tests__daemon_no_cli_handoff.snap | 12 ++ ...n_menu__tests__daemon_remote_guidance.snap | 13 ++ ...nu__tests__daemon_stable_confirmation.snap | 16 ++ ...on_menu__tests__daemon_unpackaged_cli.snap | 12 ++ codex-rs/tui/src/app/startup.rs | 2 + codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + .../src/app/tests/startup_defaults_tests.rs | 1 + codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 + .../chat_composer/agents_navigation_tests.rs | 1 + .../tui/src/bottom_pane/slash_commands.rs | 1 + ...p__tests__command_popup_default_items.snap | 1 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 2 + codex-rs/tui/src/cli.rs | 5 + codex-rs/tui/src/history_cell/notices.rs | 5 +- ..._local_daemon_version_notice_snapshot.snap | 3 +- codex-rs/tui/src/lib.rs | 3 + codex-rs/tui/src/slash_command.rs | 4 + codex-rs/tui/src/update_action.rs | 19 ++ codex-rs/tui/src/updates.rs | 1 + 31 files changed, 544 insertions(+), 18 deletions(-) create mode 100644 codex-rs/cli/src/daemon_update_tests.rs create mode 100644 codex-rs/tui/src/app/daemon_menu.rs create mode 100644 codex-rs/tui/src/app/daemon_menu_tests.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap diff --git a/codex-rs/cli/src/daemon_update_tests.rs b/codex-rs/cli/src/daemon_update_tests.rs new file mode 100644 index 0000000000..d93069f0da --- /dev/null +++ b/codex-rs/cli/src/daemon_update_tests.rs @@ -0,0 +1,37 @@ +use super::*; +use codex_tui::DaemonUpdateSource; +use pretty_assertions::assert_eq; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn daemon_handoff_uses_selected_executable_and_propagates_failure() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let executable = dir.path().join("launching CLI"); + let receipt = dir.path().join("launching CLI.args"); + std::fs::write( + &executable, + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\n", + )?; + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(/*mode*/ 0o700))?; + for (source, expected) in [ + ( + DaemonUpdateSource::PublicStable, + "app-server\ndaemon\nupdate\n", + ), + ( + DaemonUpdateSource::ThisCli, + "app-server\ndaemon\nupdate\n--from-cli\n--yes\n", + ), + ] { + run_update_action(UpdateAction::Daemon(source), Some(&executable))?; + assert_eq!(std::fs::read_to_string(&receipt)?, expected); + } + std::fs::write(&executable, "#!/bin/sh\nexit 7\n")?; + let error = run_update_action( + UpdateAction::Daemon(DaemonUpdateSource::ThisCli), + Some(&executable), + ) + .unwrap_err(); + assert!(error.to_string().contains("Daemon update failed")); + Ok(()) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 108701e58f..39048c90f6 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -887,7 +887,10 @@ fn parse_socket_path(raw: &str) -> Result { } /// Handle the app exit and print the results. Optionally run the update action. -fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { +fn handle_app_exit( + exit_info: AppExitInfo, + cli_executable: Option<&std::path::Path>, +) -> anyhow::Result<()> { let is_fatal = match &exit_info.exit_reason { ExitReason::Fatal(message) => { eprintln!("ERROR: {message}"); @@ -900,22 +903,41 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { }; let update_action = exit_info.update_action; - let color_enabled = supports_color::on(Stream::Stdout).is_some(); - for line in exit_info.format_exit_messages(color_enabled) { - println!("{line}"); + if !matches!(update_action, Some(UpdateAction::Daemon(_))) { + let color_enabled = supports_color::on(Stream::Stdout).is_some(); + for line in exit_info.format_exit_messages(color_enabled) { + println!("{line}"); + } } if is_fatal { std::io::stdout().flush()?; std::process::exit(1); } if let Some(action) = update_action { - run_update_action(action)?; + run_update_action(action, cli_executable)?; } Ok(()) } /// Run the update action and print the result. -fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { +fn run_update_action( + action: UpdateAction, + cli_executable: Option<&std::path::Path>, +) -> anyhow::Result<()> { + if let UpdateAction::Daemon(source) = action { + let executable = cli_executable + .ok_or_else(|| anyhow::anyhow!("Cannot locate the launching Codex CLI"))?; + println!("Updating the local background server..."); + let status = std::process::Command::new(executable) + .args(source.command_args()) + .status()?; + anyhow::ensure!( + status.success(), + "Daemon update failed with status {status}" + ); + println!("Relaunch Codex to reconnect."); + return Ok(()); + } println!(); let cmd_str = action.command_str(); println!("Updating Codex via `{cmd_str}`..."); @@ -996,7 +1018,7 @@ fn run_update_command() -> anyhow::Result<()> { "Could not detect the Codex installation method. Please update manually: https://developers.openai.com/codex/cli/" ); }; - run_update_action(action) + run_update_action(action, /*cli_executable*/ None) } } @@ -1158,6 +1180,14 @@ async fn cli_main( mut interactive, subcommand, } = MultitoolCli::parse(); + // Retain the launch target through TUI exit, even if a launcher changes selection. + let daemon_cli_executable = arg0_paths + .codex_self_exe + .as_ref() + .and_then(|path| path.canonicalize().ok()); + interactive.daemon_cli_executable = daemon_cli_executable + .clone() + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()); reject_unsupported_worktree_for_subcommand(interactive.shared.worktree, &subcommand)?; // Fold --enable/--disable into config overrides so they flow to all subcommands. let toggle_overrides = feature_toggles.to_overrides()?; @@ -1244,7 +1274,7 @@ async fn cli_main( arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + handle_app_exit(exit_info, daemon_cli_executable.as_deref())?; } Some(Subcommand::Exec(mut exec_cli)) => { reject_remote_mode_for_subcommand( @@ -1559,7 +1589,7 @@ async fn cli_main( arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + handle_app_exit(exit_info, daemon_cli_executable.as_deref())?; } Some(Subcommand::Archive(cmd)) => { let output = run_session_archive_cli_command( @@ -1646,7 +1676,7 @@ async fn cli_main( arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + handle_app_exit(exit_info, daemon_cli_executable.as_deref())?; } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( @@ -5217,3 +5247,7 @@ mod tests { .expect_err("feature should be rejected") } } + +#[cfg(all(test, unix))] +#[path = "daemon_update_tests.rs"] +mod daemon_update_tests; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dbbdebcd72..314fecc3a8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -214,6 +214,7 @@ mod backend_banner_fallback; mod background_requests; mod config_persistence; mod connector_mentions; +mod daemon_menu; mod event_dispatch; mod exit_summary; mod experimental_features; @@ -605,6 +606,7 @@ pub(crate) struct App { app_server_target: AppServerTarget, reconnect: reconnect::ReconnectState, /// Set when the user confirms an update; propagated on exit. + daemon_cli_executable: Option, pub(crate) pending_update_action: Option, /// Tracks the thread we intentionally shut down while exiting the app. diff --git a/codex-rs/tui/src/app/daemon_menu.rs b/codex-rs/tui/src/app/daemon_menu.rs new file mode 100644 index 0000000000..abf56cc2b0 --- /dev/null +++ b/codex-rs/tui/src/app/daemon_menu.rs @@ -0,0 +1,132 @@ +//! Local daemon maintenance only records an update action after explicit confirmation. +//! The CLI executes it in the foreground after the TUI restores the terminal. + +use super::*; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::update_action::DaemonUpdateSource; +use crate::wrapping::word_wrap_lines; +use ratatui::buffer::Buffer; +use ratatui::widgets::Paragraph; + +struct DaemonMenuHeader(Vec>); + +impl Renderable for DaemonMenuHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + Renderable::render( + &Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))), + area, + buf, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + word_wrap_lines(&self.0, usize::from(width)).len() as u16 + } +} + +impl App { + pub(super) fn open_daemon_menu(&mut self) { + let status = self + .chat_widget + .remote_connection + .as_ref() + .filter(|_| matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. })) + .map(|connection| format!("Running daemon: {}", connection.version)) + .unwrap_or_else(|| "Not connected to the local background server.".to_string()); + let mut header = vec![Line::from("Daemon".bold()), Line::from(status.dim())]; + let unavailable = if matches!(self.app_server_target, AppServerTarget::Remote { .. }) { + Some( + "Manage this server on its host. Local daemon updates are unavailable for remote connections.", + ) + } else if self.daemon_cli_executable.is_none() { + Some("Run the Codex CLI to manage the daemon from this menu.") + } else { + None + }; + if let Some(guidance) = unavailable { + header.push(Line::from(guidance.dim())); + } + let has_package = self.daemon_cli_executable.as_ref().is_some_and(|path| { + codex_install_context::InstallContext::from_exe( + cfg!(target_os = "macos"), + Some(path.as_path()), + /*method_override*/ None, + ) + .package_layout + .is_some() + }); + let items = [ + ( + DaemonUpdateSource::PublicStable, + "Install latest public stable", + ), + (DaemonUpdateSource::ThisCli, "Use this CLI build"), + ] + .into_iter() + .map(|(source, name)| SelectionItem { + name: name.to_string(), + is_disabled: unavailable.is_some(), + disabled_reason: (unavailable.is_none() + && source == DaemonUpdateSource::ThisCli + && !has_package) + .then(|| "This CLI has no local package to copy.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConfirmDaemonUpdate(source)); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + self.chat_widget.show_selection_view(SelectionViewParams { + header: Box::new(DaemonMenuHeader(header)), + items, + ..Default::default() + }); + } + + pub(super) fn confirm_daemon_update(&mut self, source: DaemonUpdateSource) { + let Some(executable) = &self.daemon_cli_executable else { + return; + }; + if matches!(self.app_server_target, AppServerTarget::Remote { .. }) { + return; + } + let mut explanation = match source { + DaemonUpdateSource::PublicStable => "Install the latest public stable release (version resolved when the command runs). Production update eligibility will be restored; your automatic-update setting is preserved.".to_string(), + DaemonUpdateSource::ThisCli => { + let version = codex_install_context::InstallContext::current() + .package_manifest() + .map_or_else(|| CODEX_CLI_VERSION.to_string(), |manifest| manifest.version.to_string()); + format!("Use this CLI package v{version} from {}. The complete local package will be copied and pinned against automatic updates.", executable.display()) + } + }; + explanation.push_str("\nThe daemon will restart if needed. Active or queued work may be interrupted.\nCodex will exit and run the update in this terminal, then return to the shell. Relaunch Codex afterward."); + let mut header = vec![Line::from("Update daemon and exit Codex?".bold())]; + header.extend(explanation.lines().map(|line| Line::from(line.to_owned()))); + self.chat_widget.show_selection_view(SelectionViewParams { + header: Box::new(DaemonMenuHeader(header)), + items: vec![ + SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Update and exit".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::RunDaemonUpdate(source)) + })], + require_explicit_confirmation: true, + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } +} + +#[cfg(test)] +#[path = "daemon_menu_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/daemon_menu_tests.rs b/codex-rs/tui/src/app/daemon_menu_tests.rs new file mode 100644 index 0000000000..8991b49309 --- /dev/null +++ b/codex-rs/tui/src/app/daemon_menu_tests.rs @@ -0,0 +1,168 @@ +use super::*; +use crate::app::test_support::make_test_app; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use crate::status::remote_connection::RemoteConnectionStatus; +use crossterm::event::KeyCode; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn daemon_menu_is_read_only_and_confirmation_can_cancel_or_handoff() { + let mut app = make_test_app().await; + let (chat, _, mut rx, _) = make_chatwidget_manual_with_sender().await; + app.chat_widget = chat; + let package = tempfile::tempdir().unwrap(); + std::fs::create_dir(package.path().join("bin")).unwrap(); + std::fs::write(package.path().join("bin/codex"), "CLI").unwrap(); + std::fs::write(package.path().join("codex-package.json"), "{}").unwrap(); + app.daemon_cli_executable = + Some(AbsolutePathBuf::from_absolute_path(package.path().join("bin/codex")).unwrap()); + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock").unwrap(), + }, + }; + app.chat_widget.remote_connection = Some(RemoteConnectionStatus { + address: "local".into(), + version: "v0.153.0".into(), + }); + app.initialize_server_version_notice("0.154.0", Some("0.153.0")); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let overview = render_bottom_popup(&app.chat_widget, /*width*/ 80); + insta::assert_snapshot!(overview.lines().next().unwrap().trim(), @"Service v0.153.0 < Codex CLI v0.154.0 · /daemon"); + let executable = app.daemon_cli_executable.clone(); + for (width, source, snapshot) in [ + ( + 80, + DaemonUpdateSource::PublicStable, + "daemon_stable_confirmation", + ), + (100, DaemonUpdateSource::ThisCli, "daemon_cli_confirmation"), + ] { + app.daemon_cli_executable = executable.clone(); + app.open_daemon_menu(); + if source == DaemonUpdateSource::PublicStable { + insta::assert_snapshot!( + "daemon_menu", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + } else { + app.chat_widget.handle_key_event(KeyCode::Down.into()); + } + assert!(rx.try_recv().is_err()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + matches!(rx.try_recv().unwrap(), AppEvent::ConfirmDaemonUpdate(selected) if selected == source) + ); + app.daemon_cli_executable = Some( + AbsolutePathBuf::from_absolute_path(if cfg!(windows) { + r"C:\cli-build\bin\codex" + } else { + "/x/cli-build/bin/codex" + }) + .unwrap(), + ); + app.confirm_daemon_update(source); + insta::assert_snapshot!( + snapshot, + render_bottom_popup(&app.chat_widget, width) + .replace(r"C:\cli-build\bin\codex", "/x/cli-build/bin/codex") + ); + // The default choice cancels without emitting an update or exiting. + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); + assert_eq!(app.pending_update_action, None); + app.confirm_daemon_update(source); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + matches!(rx.try_recv().unwrap(), AppEvent::RunDaemonUpdate(selected) if selected == source) + ); + } + + // Maintenance remains available when startup selects the embedded server. + app.app_server_target = AppServerTarget::Embedded; + app.chat_widget.remote_connection = None; + app.daemon_cli_executable = executable; + for source in [ + DaemonUpdateSource::PublicStable, + DaemonUpdateSource::ThisCli, + ] { + app.open_daemon_menu(); + if source == DaemonUpdateSource::PublicStable { + insta::assert_snapshot!( + "daemon_disconnected", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + } else { + app.chat_widget.handle_key_event(KeyCode::Down.into()); + } + assert!(rx.try_recv().is_err()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + matches!(rx.try_recv().unwrap(), AppEvent::ConfirmDaemonUpdate(selected) if selected == source) + ); + app.confirm_daemon_update(source); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); + assert_eq!(app.pending_update_action, None); + app.confirm_daemon_update(source); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + matches!(rx.try_recv().unwrap(), AppEvent::RunDaemonUpdate(selected) if selected == source) + ); + } + + std::fs::remove_file(package.path().join("codex-package.json")).unwrap(); + // The full CLI can have any filename; capability comes from its entry point. + app.daemon_cli_executable = + Some(AbsolutePathBuf::from_absolute_path(package.path().join("bin/codex-tui")).unwrap()); + app.open_daemon_menu(); + insta::assert_snapshot!( + "daemon_unpackaged_cli", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(matches!( + rx.try_recv().unwrap(), + AppEvent::ConfirmDaemonUpdate(DaemonUpdateSource::PublicStable) + )); + + app.daemon_cli_executable = None; + app.open_daemon_menu(); + insta::assert_snapshot!( + "daemon_no_cli_handoff", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); +} + +#[tokio::test] +async fn unavailable_daemon_menu_offers_guidance_without_update_actions() { + let mut app = make_test_app().await; + app.daemon_cli_executable = + Some(AbsolutePathBuf::from_absolute_path(std::env::current_exe().unwrap()).unwrap()); + let (chat, _, mut rx, _) = make_chatwidget_manual_with_sender().await; + app.chat_widget = chat; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://example.test:1234".into(), + auth_token: None, + }, + }; + app.chat_widget.remote_connection = Some(RemoteConnectionStatus { + address: "ws://example.test:1234".into(), + version: "v0.153.0".into(), + }); + app.open_daemon_menu(); + insta::assert_snapshot!( + "daemon_remote_guidance", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); + assert_eq!(app.pending_update_action, None); +} diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f99cfba2b0..9cde01d64d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -34,7 +34,10 @@ impl App { if self.reconnect.offline && !matches!( &event, - AppEvent::InsertHistoryCell(_) + AppEvent::OpenDaemonMenu + | AppEvent::ConfirmDaemonUpdate(_) + | AppEvent::RunDaemonUpdate(_) + | AppEvent::InsertHistoryCell(_) | AppEvent::CommitRealtimeTranscriptHistory | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::FinishPromptRevert { .. } @@ -94,6 +97,12 @@ impl App { } match event { + AppEvent::OpenDaemonMenu => self.open_daemon_menu(), + AppEvent::ConfirmDaemonUpdate(source) => self.confirm_daemon_update(source), + AppEvent::RunDaemonUpdate(source) => { + self.pending_update_action = Some(UpdateAction::Daemon(source)); + return Ok(self.handle_exit_mode(app_server, ExitMode::Immediate).await); + } AppEvent::UserVerificationApproved { thread_id, server_name, request_id } => { Box::pin(self.start_user_verification(app_server, thread_id, server_name, request_id)).await?; } diff --git a/codex-rs/tui/src/app/server_version_notice.rs b/codex-rs/tui/src/app/server_version_notice.rs index b912cc9d61..48078d5433 100644 --- a/codex-rs/tui/src/app/server_version_notice.rs +++ b/codex-rs/tui/src/app/server_version_notice.rs @@ -1,4 +1,4 @@ -//! Keeps the older remote server notice in the overview's shared view state. +//! Keeps the older server notice and local maintenance guidance in the overview. use super::*; @@ -47,7 +47,14 @@ impl App { .view_state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .server_version_notice = - older_server.map(|server| format!("Service v{server} < Codex CLI v{client_version}")); + .server_version_notice = older_server.map(|server| { + let guidance = if matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. }) + { + " · /daemon" + } else { + "" + }; + format!("Service v{server} < Codex CLI v{client_version}{guidance}") + }); } } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap new file mode 100644 index 0000000000..7a8fb7160c --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")" +--- + Update daemon and exit Codex? + Use this CLI package v0.0.0 from /x/cli-build/bin/codex. The complete local package will be + copied and pinned against automatic updates. + The daemon will restart if needed. Active or queued work may be interrupted. + Codex will exit and run the update in this terminal, then return to the shell. Relaunch Codex + afterward. + +› 1. Cancel + 2. Update and exit + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap new file mode 100644 index 0000000000..b28f674e84 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + +› 1. Install latest public stable + 2. Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap new file mode 100644 index 0000000000..24f632d8a8 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Running daemon: v0.153.0 + +› 1. Install latest public stable + 2. Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap new file mode 100644 index 0000000000..61e52edc63 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + Run the Codex CLI to manage the daemon from this menu. + +› Install latest public stable + Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap new file mode 100644 index 0000000000..f590a18d74 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + Manage this server on its host. Local daemon updates are unavailable for + remote connections. + +› Install latest public stable + Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap new file mode 100644 index 0000000000..f8028d94b4 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")" +--- + Update daemon and exit Codex? + Install the latest public stable release (version resolved when the command + runs). Production update eligibility will be restored; your automatic-update + setting is preserved. + The daemon will restart if needed. Active or queued work may be interrupted. + Codex will exit and run the update in this terminal, then return to the + shell. Relaunch Codex afterward. + +› 1. Cancel + 2. Update and exit + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap new file mode 100644 index 0000000000..17f12ed62e --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + +› 1. Install latest public stable + Use this CLI build (disabled) disabled: This CLI has no local package to + copy. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 7dc0d525d4..2f972d3155 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -156,6 +156,7 @@ impl App { startup_hooks_browser: Option, mut startup_draft: StartupDraftPump, managed_worktree: Option, + daemon_cli_executable: Option, ) -> Result { use tokio_stream::StreamExt; @@ -756,6 +757,7 @@ See the Codex keymap documentation for supported actions and examples." .map(|(_, key)| key.clone()), ..Default::default() }, + daemon_cli_executable, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index ae6be36928..1d81fe1619 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -62,6 +62,7 @@ pub(super) async fn make_test_app() -> App { environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), + daemon_cli_executable: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index df66b75320..7a28c612d2 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5924,6 +5924,7 @@ async fn make_test_app() -> App { environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), + daemon_cli_executable: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -6024,6 +6025,7 @@ pub(super) async fn make_test_app_with_channels() -> ( environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), + daemon_cli_executable: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs index 6311364e30..4d3ee2165a 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -35,6 +35,7 @@ async fn run_startup_for_test( /*startup_hooks_browser*/ None, crate::startup_draft::tests::quiet_startup_test_pump(), /*managed_worktree*/ None, + /*daemon_cli_executable*/ None, ) .await } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fd084f36a7..f29095ada9 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -268,6 +268,9 @@ pub(crate) struct AgentsOverviewThreadRefresh { #[allow(clippy::large_enum_variant)] #[derive(Debug, IntoStaticStr)] pub(crate) enum AppEvent { + OpenDaemonMenu, + ConfirmDaemonUpdate(crate::update_action::DaemonUpdateSource), + RunDaemonUpdate(crate::update_action::DaemonUpdateSource), ReviewMisalignment(Arc), ContinueMisalignment(Arc), CloseMisalignmentReview, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7a6eeb5461..2b86759a67 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -441,6 +441,7 @@ fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool { | SlashCommand::Import | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Daemon | SlashCommand::Usage | SlashCommand::Ide | SlashCommand::DebugConfig diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs index 7796587ff3..abe8f0de2d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs @@ -13,6 +13,7 @@ fn parent_owned_thread_allows_bare_navigation_commands() { ("/side", SlashCommand::Side), ("/btw", SlashCommand::Btw), ("/diff ", SlashCommand::Diff), + ("/daemon", SlashCommand::Daemon), ] { let (mut composer, _rx) = new_test_composer(); composer.set_parent_owned_thread(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 184985eb43..d3723a42dc 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -332,6 +332,7 @@ mod tests { SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, + SlashCommand::Daemon, SlashCommand::Pwd, SlashCommand::Usage, ] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index 91b583d43e..8e631793d1 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -32,6 +32,7 @@ expression: commands /diff - show git diff (including untracked files) /mention - mention a file /status - show current session configuration and token usage +/daemon - Manage the local background server. /cd - change the current working directory /pwd - show the current working directory /title - configure which items appear in the terminal title diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 9b2b43a93e..e97f014730 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -476,6 +476,7 @@ impl ChatWidget { SlashCommand::Hooks => { self.add_hooks_output(); } + SlashCommand::Daemon => self.app_event_tx.send(AppEvent::OpenDaemonMenu), SlashCommand::Status => { if self.should_prefetch_rate_limits() { let request_id = self.next_status_refresh_request_id; @@ -1174,6 +1175,7 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Daemon | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::DebugConfig diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 641dd621e4..c6379e59aa 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,6 +1,7 @@ use clap::Args; use clap::FromArgMatches; use clap::Parser; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::ApprovalModeCliArg; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::SharedCliOptions; @@ -8,6 +9,10 @@ use codex_utils_cli::SharedCliOptions; #[derive(Parser, Clone, Debug)] #[command(version)] pub struct Cli { + /// Internal: launching CLI that handles daemon updates after the TUI exits. + #[clap(skip)] + pub daemon_cli_executable: Option, + /// Optional user prompt to start the session. #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] pub prompt: Option, diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 18bcd20b3a..0208e3d15f 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -97,8 +97,9 @@ pub(crate) fn new_server_version_warning( ) -> PrefixedWrappedHistoryCell { let mut lines = vec![Line::from(notice.message.yellow())]; if notice.offer_update { - lines.push(Line::from("To update the service, run:".yellow())); - lines.push(Line::from(" codex app-server daemon update".cyan())); + lines.push(Line::from( + "Use /daemon to manage the local background server.".cyan(), + )); lines.push(Line::from( "Updating may interrupt active or queued work.".yellow(), )); diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap index df1d7b24a1..e3ade9c703 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap @@ -3,6 +3,5 @@ source: tui/src/history_cell/tests.rs expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")" --- ⚠ A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0. - To update the service, run: - codex app-server daemon update + Use /daemon to manage the local background server. Updating may interrupt active or queued work. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8b9c20eb79..b87ab02710 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -223,6 +223,7 @@ mod ui_consts; mod unarchive_prompt; pub(crate) mod update_action; mod worktree_startup; +pub use update_action::DaemonUpdateSource; pub use update_action::UpdateAction; #[cfg(not(debug_assertions))] pub use update_action::get_update_action; @@ -1816,6 +1817,7 @@ async fn run_ratatui_app( prompt, shared, no_alt_screen, + daemon_cli_executable, .. } = cli; let images = shared.into_inner().images; @@ -1908,6 +1910,7 @@ async fn run_ratatui_app( startup_hooks_browser, startup_draft, managed_worktree, + daemon_cli_executable, )) .await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index dd0c1e41a3..24b4af75cc 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -50,6 +50,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Daemon, Cd, #[strum(to_string = "pwd", serialize = "cwd")] Pwd, @@ -109,6 +110,7 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", + SlashCommand::Daemon => "Manage the local background server.", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Cd => "change the current working directory", SlashCommand::Pwd => "show the current working directory", @@ -194,6 +196,7 @@ impl SlashCommand { | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Daemon | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::Ide @@ -236,6 +239,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Daemon | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::DebugConfig diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 225c457086..860d0bd102 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -8,6 +8,8 @@ use codex_install_context::StandalonePlatform; /// Update action the CLI should perform after the TUI exits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateAction { + /// Replace the local daemon after restoring the terminal. + Daemon(DaemonUpdateSource), /// Update via `npm install -g @openai/codex@latest`. NpmGlobalLatest, /// Update via `bun install -g @openai/codex@latest`. @@ -44,6 +46,7 @@ impl UpdateAction { /// Returns the list of command-line arguments for invoking the update. pub fn command_args(self) -> (&'static str, &'static [&'static str]) { match self { + UpdateAction::Daemon(source) => ("codex", source.command_args()), UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), UpdateAction::VitePlusGlobalLatest => ("vp", &["install", "-g", "@openai/codex"]), @@ -178,3 +181,19 @@ mod tests { ); } } + +/// Package source explicitly selected by the user in the daemon menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DaemonUpdateSource { + PublicStable, + ThisCli, +} + +impl DaemonUpdateSource { + pub fn command_args(self) -> &'static [&'static str] { + match self { + Self::PublicStable => &["app-server", "daemon", "update"], + Self::ThisCli => &["app-server", "daemon", "update", "--from-cli", "--yes"], + } + } +} diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index b801426552..9bbdf310a8 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -82,6 +82,7 @@ async fn check_for_update( ) .with_legacy_custom_ca_fallback(); let latest_version = match action { + Some(UpdateAction::Daemon(_)) => return Ok(()), Some(UpdateAction::BrewUpgrade) => { let HomebrewCaskInfo { version } = client_pool .get(HOMEBREW_CASK_API_URL)