mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
+11
@@ -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
|
||||
+12
@@ -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
|
||||
+13
@@ -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
|
||||
+16
@@ -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
|
||||
+12
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
|
||||
+1
-2
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user