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
This commit is contained in:
Eric Traut
2026-09-16 03:43:59 +00:00
committed by copyberry
parent 7322c5e790
commit 04581f9604
31 changed files with 544 additions and 18 deletions
+37
View File
@@ -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(())
}
+44 -10
View File
@@ -887,7 +887,10 @@ fn parse_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
}
/// 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;
+2
View File
@@ -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<AbsolutePathBuf>,
pub(crate) pending_update_action: Option<UpdateAction>,
/// Tracks the thread we intentionally shut down while exiting the app.
+132
View File
@@ -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<Line<'static>>);
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;
+168
View File
@@ -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);
}
+10 -1
View File
@@ -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?;
}
+10 -3
View File
@@ -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}")
});
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+2
View File
@@ -156,6 +156,7 @@ impl App {
startup_hooks_browser: Option<HooksListEntry>,
mut startup_draft: StartupDraftPump,
managed_worktree: Option<crate::ManagedTuiWorktree>,
daemon_cli_executable: Option<AbsolutePathBuf>,
) -> Result<AppExitInfo> {
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 {
+1
View File
@@ -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(),
+2
View File
@@ -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(),
@@ -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
}
+3
View File
@@ -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<crate::chatwidget::MisalignmentReview>),
ContinueMisalignment(Arc<crate::chatwidget::MisalignmentReview>),
CloseMisalignmentReview,
@@ -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
@@ -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();
@@ -332,6 +332,7 @@ mod tests {
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
SlashCommand::Daemon,
SlashCommand::Pwd,
SlashCommand::Usage,
]
@@ -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
@@ -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
+5
View File
@@ -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<AbsolutePathBuf>,
/// Optional user prompt to start the session.
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
pub prompt: Option<String>,
+3 -2
View File
@@ -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(),
));
@@ -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.
+3
View File
@@ -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;
+4
View File
@@ -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
+19
View File
@@ -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"],
}
}
}
+1
View File
@@ -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)