diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c440738fd..9d540c3124 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2444,11 +2444,12 @@ async fn run_interactive_tui( cloud_config::load_config(&interactive.config_overrides, LoaderOverrides::default()) .await .map_err(std::io::Error::other)?; - codex_app_server_daemon::run(AppServerLifecycleCommand::Start) + let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Start) .await .map_err(|err| std::io::Error::other(format!( "{err:#}\nThe agents overview requires a shared server. Use codex --no-daemon to work without it." )))?; + interactive.managed_daemon = output.backend.is_some(); } let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) { diff --git a/codex-rs/cli/tests/daemon_startup.rs b/codex-rs/cli/tests/daemon_startup.rs index 12bcba2bcf..1a2cdaee73 100644 --- a/codex-rs/cli/tests/daemon_startup.rs +++ b/codex-rs/cli/tests/daemon_startup.rs @@ -14,6 +14,18 @@ async fn auto_daemon_start_attaches_to_shared_server() -> Result<()> { daemon_startup("start").await } +#[tokio::test] +#[cfg(unix)] +async fn agents_can_offer_restart_for_managed_daemon() -> Result<()> { + daemon_startup("agents").await +} + +#[tokio::test] +#[cfg(unix)] +async fn discovered_managed_daemon_can_restart_with_auto_start_disabled() -> Result<()> { + daemon_startup("discovered").await +} + #[tokio::test] #[cfg(unix)] async fn incompatible_daemon_can_cancel() -> Result<()> { @@ -159,8 +171,14 @@ async fn daemon_startup(command: &str) -> Result<()> { suppress_unstable_features_warning = true\nanalytics.enabled = false\n\ windows.sandbox = \"unelevated\"\n\ tui.disable_paste_burst = true\n\ + {}\n\ notice.model_migrations.\"gpt-5.6-terra\" = \"gpt-6-sol\"\n\ [projects.{}]\ntrust_level = \"trusted\"\n", + if command == "discovered" { + "tui.status_line=[\"model\",\"thread-id\"]" + } else { + "" + }, serde_json::to_string(&workspace_path)?, ), )?; @@ -196,7 +214,11 @@ async fn daemon_startup(command: &str) -> Result<()> { env.insert("TERM".into(), "xterm-256color".into()); let mut args = vec!["--no-alt-screen".to_string()]; let mut steps: VecDeque<(&str, &[u8])> = VecDeque::new(); - if matches!(command, "start" | "bedrock-running" | "restrictive-job") || mismatch { + if matches!( + command, + "start" | "agents" | "discovered" | "bedrock-running" | "restrictive-job" + ) || mismatch + { // A selected package with a stopped daemon avoids installing a release. let managed = home .path() @@ -223,7 +245,9 @@ async fn daemon_startup(command: &str) -> Result<()> { } let pid_file = home.path().join("app-server-daemon/daemon.pid"); let result = async { - let mut existing_daemon = if command == "bedrock-running" || (mismatch && !disabling) { + let mut existing_daemon = if matches!(command, "bedrock-running" | "discovered") + || (mismatch && !disabling) + { let started = Command::new(&codex) .env_clear() .envs(&env) @@ -243,6 +267,19 @@ async fn daemon_startup(command: &str) -> Result<()> { // The draft header is visible before the session's command composer is ready. steps.push_back(("GPT-5.6-Terra", b"/status\r")); "Server:Localbackgroundserver" + } else if command == "agents" { + args.push("agents".into()); + steps.push_back(("Notasksyet", b"\x1b")); + steps.push_back(("GPT-5.6-Terra", b"/daemon\r")); + steps.push_back(("Restartbackgroundserver", b"\x1b[F\r")); + "Restartbackgroundserver?" + } else if command == "discovered" { + args.extend(["--disable".into(), "daemon_auto_start".into()]); + steps.push_back(("GPT-5.6-Terra", b"/daemon\r")); + steps.push_back(("Restartbackgroundserver", b"2")); + steps.push_back(("Restartbackgroundserver?", b"2\r")); + steps.push_back(("GPT-5.6-Terra", b"/status\r")); + "Server:Localbackgroundserver" } else if elevated_launch || command == "restrictive-job" && elevated { if let Some(action) = command.strip_prefix("elevated-") { args.push(action.into()); @@ -344,6 +381,27 @@ async fn daemon_startup(command: &str) -> Result<()> { if let Some((ready, input)) = steps.front() && text.contains(ready) { + if command == "discovered" { + if *ready == "Restartbackgroundserver?" { + existing_daemon = Some(fs::read(&pid_file)?); + } else if *ready == "GPT-5.6-Terra" { + // The header renders before thread/start completes. Wait for the + // live status line to confirm there is a session to reconnect. + if !screen.screen().contents().split_whitespace().any(|part| { + codex_protocol::ThreadId::from_string(part).is_ok() + }) { + continue; + } + // The old header can linger while the daemon stops. Do not send + // /status until the new process is up and reconnect has finished. + if steps.len() == 1 + && (!fs::read(&pid_file).is_ok_and(|pid| Some(pid) != existing_daemon) + || text.contains("Reconnecting")) + { + continue; + } + } + } if disabling && *ready == "Backgroundserverhasincompatiblefeaturesettings" { existing_daemon = Some(fs::read(&pid_file)?); let settings: serde_json::Value = serde_json::from_slice(&fs::read(home.path().join("app-server-daemon/settings.json"))?)?; @@ -379,6 +437,9 @@ async fn daemon_startup(command: &str) -> Result<()> { } else if command == "start" { ensure!(text.contains("Server:Localbackgroundserver")); ensure!(home.path().join("app-server-daemon/daemon.pid").exists()); + } else if command == "discovered" { + let previous_pid = existing_daemon.as_ref().context("missing original daemon PID")?; + ensure!(fs::read(&pid_file)? != *previous_pid, "daemon PID did not change"); } else if let Some(existing_daemon) = &existing_daemon { ensure!(fs::read(&pid_file)? == *existing_daemon); } else if elevated_launch { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a25123f105..1b07e9826d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -606,6 +606,8 @@ pub(crate) struct App { /// Set when the user confirms an update; propagated on exit. daemon_cli_executable: Option, pub(crate) pending_update_action: Option, + /// Retained through all loop exits so shutdown cannot interrupt stop followed by start. + daemon_restart: 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 index d5a354a0e9..ada9e88080 100644 --- a/codex-rs/tui/src/app/daemon_menu.rs +++ b/codex-rs/tui/src/app/daemon_menu.rs @@ -1,5 +1,5 @@ -//! Local daemon maintenance only records an update action after explicit confirmation. -//! The CLI executes it in the foreground after the TUI restores the terminal. +//! Local daemon maintenance confirms restarts and updates before acting. +//! Restarts use normal TUI reconnection; updates run after restoring the terminal. use super::*; use crate::bottom_pane::SelectionItem; @@ -26,6 +26,21 @@ impl Renderable for DaemonMenuHeader { } impl App { + pub(super) async fn verify_managed_daemon(&mut self) { + // A discovered socket alone does not prove it belongs to a managed daemon. + // Probe only when the user opens a view that offers restart, never during startup. + if !self.reconnect.offline + && let Some(connection) = &mut self.chat_widget.remote_connection + && connection.is_local_daemon + && !connection.is_managed_daemon + { + connection.is_managed_daemon = + codex_app_server_daemon::run(codex_app_server_daemon::LifecycleCommand::Version) + .await + .is_ok_and(|output| output.backend.is_some()); + } + } + pub(super) fn open_daemon_menu(&mut self) { let status = self .chat_widget @@ -56,7 +71,7 @@ impl App { .package_layout .is_some() }); - let items = [ + let mut items: Vec = [ ( DaemonUpdateSource::PublicStable, "Install latest public stable", @@ -78,6 +93,28 @@ impl App { ..Default::default() }) .collect(); + items.push(SelectionItem { + name: "Restart background server".to_string(), + is_disabled: !self + .chat_widget + .remote_connection + .as_ref() + .is_some_and(|connection| connection.is_managed_daemon) + || self.reconnect.offline + || self.daemon_restart.is_some(), + disabled_reason: self + .chat_widget + .remote_connection + .as_ref() + .filter(|connection| connection.is_local_daemon && !connection.is_managed_daemon) + .map(|_| { + "Codex hasn't verified it manages this server. Restart it where it was started." + .to_string() + }), + actions: vec![Box::new(|tx| tx.send(AppEvent::ConfirmDaemonRestart))], + dismiss_on_select: true, + ..Default::default() + }); self.chat_widget.show_selection_view(SelectionViewParams { header: Box::new(DaemonMenuHeader(header)), items, @@ -85,6 +122,40 @@ impl App { }); } + pub(super) fn confirm_daemon_restart(&mut self) { + if !self + .chat_widget + .remote_connection + .as_ref() + .is_some_and(|connection| connection.is_managed_daemon) + || self.daemon_restart.is_some() + { + return; + } + self.chat_widget.show_selection_view(SelectionViewParams { + header: Box::new(DaemonMenuHeader(vec![ + Line::from("Restart background server?".bold()), + Line::from("Active or queued work in other chats may be interrupted."), + Line::from("Codex will reconnect automatically."), + ])), + items: vec![ + SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Restart".to_string(), + actions: vec![Box::new(|tx| tx.send(AppEvent::RunDaemonRestart))], + require_explicit_confirmation: true, + dismiss_on_select: true, + ..Default::default() + }, + ], + ..SelectionViewParams::confirmation() + }); + } + pub(super) fn confirm_daemon_update(&mut self, source: DaemonUpdateSource) { let Some(executable) = &self.daemon_cli_executable else { return; diff --git a/codex-rs/tui/src/app/daemon_menu_tests.rs b/codex-rs/tui/src/app/daemon_menu_tests.rs index 0928bf31f0..0498495092 100644 --- a/codex-rs/tui/src/app/daemon_menu_tests.rs +++ b/codex-rs/tui/src/app/daemon_menu_tests.rs @@ -83,6 +83,7 @@ async fn daemon_menu_is_read_only_and_confirmation_can_cancel_or_handoff() { address: "local".into(), version: "v0.153.0".into(), is_local_daemon: true, + is_managed_daemon: true, }); app.initialize_server_version_notice("0.154.0", Some("0.153.0")); let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); @@ -139,6 +140,100 @@ async fn daemon_menu_is_read_only_and_confirmation_can_cancel_or_handoff() { ); } + app.daemon_cli_executable = executable.clone(); + app.open_daemon_menu(); + let mut terminal = crate::custom_terminal::Terminal::with_options( + crate::test_backend::VT100Backend::new(/*width*/ 120, /*height*/ 30), + ) + .unwrap(); + let area = Rect::new(0, 0, 120, 30); + terminal.set_viewport_area(area); + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (229, 229, 229), + bg: (30, 40, 56), + }, + || { + terminal + .draw(|frame| app.chat_widget.render(area, frame.buffer_mut())) + .unwrap() + }, + ); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + 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::ConfirmDaemonRestart + )); + app.confirm_daemon_restart(); + insta::assert_snapshot!( + "daemon_restart_confirmation", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (229, 229, 229), + bg: (30, 40, 56), + }, + || { + // Simulate a terminal that erased the menu's blank tails with its default + // background rather than the cached panel color. + use std::io::Write; + for y in 0..area.height { + write!(terminal.backend_mut(), "\x1b[{};25H\x1b[0m\x1b[K", y + 1).unwrap(); + } + terminal + .draw(|frame| app.chat_widget.render(area, frame.buffer_mut())) + .unwrap(); + let expected = crate::custom_terminal::test_support::last_rendered_buffer(&terminal); + let screen = terminal.backend().vt100().screen(); + for y in 0..area.height { + for x in 24..96 { + let expected_bg = match expected[(x, y)].bg { + ratatui::style::Color::Rgb(r, g, b) => vt100::Color::Rgb(r, g, b), + ratatui::style::Color::Reset => vt100::Color::Default, + _ => continue, + }; + assert_eq!( + screen.cell(y, x).unwrap().bgcolor(), + expected_bg, + "at {x}, {y}: {:?}", + expected[(x, y)] + ); + } + } + }, + ); + // Cancel is the default; a confirmed restart stays in the TUI. + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(rx.try_recv().is_err()); + app.confirm_daemon_restart(); + 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::RunDaemonRestart)); + + // A local socket alone does not mean Codex can restart its owner. + app.chat_widget + .remote_connection + .as_mut() + .unwrap() + .is_managed_daemon = false; + app.open_daemon_menu(); + insta::assert_snapshot!( + "daemon_unmanaged", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + 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::ConfirmDaemonUpdate(_) + )); + app.confirm_daemon_restart(); + assert!(rx.try_recv().is_err()); + // Maintenance remains available when startup selects the embedded server. app.app_server_target = AppServerTarget::Embedded; app.chat_widget.remote_connection = None; @@ -215,6 +310,7 @@ async fn unavailable_daemon_menu_offers_guidance_without_update_actions() { address: "ws://example.test:1234".into(), version: "v0.153.0".into(), is_local_daemon: false, + is_managed_daemon: false, }); app.open_daemon_menu(); insta::assert_snapshot!( diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7808d3eaf7..23e8ece57f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -138,6 +138,10 @@ impl App { return Ok(AppRunControl::Continue); } + if matches!(event, AppEvent::OpenDaemonMenu | AppEvent::OpenWarnings) { + self.verify_managed_daemon().await; + } + // Keep the picker check and model update in one event without recursively polling this // large dispatcher on the TUI thread's stack. let (event, sparkle_model) = match event { @@ -155,6 +159,16 @@ impl App { }; match event { AppEvent::OpenDaemonMenu => self.open_daemon_menu(), + AppEvent::ConfirmDaemonRestart => self.confirm_daemon_restart(), + AppEvent::RunDaemonRestart => { + if self.chat_widget.remote_connection.as_ref().is_some_and(|connection| connection.is_managed_daemon) + && self.daemon_restart.is_none() + { + self.daemon_restart = Some(tokio::spawn(codex_app_server_daemon::run( + codex_app_server_daemon::LifecycleCommand::Restart, + ))); + } + } AppEvent::ConfirmDaemonUpdate(source) => self.confirm_daemon_update(source), AppEvent::RunDaemonUpdate(source) => { self.pending_update_action = Some(UpdateAction::Daemon(source)); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 877b329693..f1c6dec705 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -685,6 +685,7 @@ impl App { key_event: KeyEvent, ) -> bool { if self.keymap.app.open_warnings.is_pressed(key_event) { + self.verify_managed_daemon().await; self.chat_widget.open_warnings(&self.transcript_cells); return true; } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 7c44213105..97e8d65d11 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -338,11 +338,19 @@ impl App { && app_server.app_server_platform_os() == Some("windows"); self.chat_widget.windows_sandbox_host = WindowsSandboxHost::Unknown; self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth; + let managed_daemon = self + .chat_widget + .remote_connection + .as_ref() + .is_some_and(|connection| connection.is_managed_daemon); self.chat_widget.remote_connection = crate::status::remote_connection::remote_connection_status_value( &self.app_server_target, app_server.server_version(), ); + if let Some(connection) = &mut self.chat_widget.remote_connection { + connection.is_managed_daemon = connection.is_local_daemon && managed_daemon; + } self.workspace_command_runner = Some(Arc::new(AppServerWorkspaceCommandRunner::new( app_server.request_handle(), ))); 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 index 70a761197d..062ad78d0d 100644 --- 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 @@ -8,5 +8,6 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" › 1. Install latest public stable 2. Use this CLI build + Restart background server enter select · esc 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 index bc8a91e3ea..868e716ca5 100644 --- 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 @@ -8,5 +8,6 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" › 1. Install latest public stable 2. Use this CLI build + 3. Restart background server enter select · esc 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 index 8232050e7b..696668613e 100644 --- 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 @@ -9,5 +9,6 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" › Install latest public stable Use this CLI build + Restart background server enter select · esc 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 index ea209abfb4..0ca2978835 100644 --- 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 @@ -10,5 +10,6 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" › Install latest public stable Use this CLI build + Restart background server enter select · esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_restart_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_restart_confirmation.snap new file mode 100644 index 0000000000..a255f34b76 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_restart_confirmation.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Agent command center Group: Project g + All 0 Needs you 0 Working 0 Ready 0 Inactive 0 + ──────────────────────────────────────────────────────────────────────────── + No tasks yet + + + + + Restart background server? + Active or queued work in other chats may be interrupted. + Codex will reconnect automatically. + + + › 1. Cancel + 2. Restart + + enter select · esc back + + + + + + + Service v0.153.0 < Codex CLI v0.154.0 · /daemon diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unmanaged.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unmanaged.snap new file mode 100644 index 0000000000..1cdc06cd11 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unmanaged.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +assertion_line: 223 +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Running daemon: v0.153.0 + + +› 1. Install latest public stable + 2. Use this CLI build + Restart background server (disabled) Codex hasn't verified it manages this + server. Restart it where it was + started. + + enter select · esc 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 index dd6f597fdc..59a21ec2dd 100644 --- 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 @@ -8,5 +8,6 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" › 1. Install latest public stable Use this CLI build (disabled) This CLI has no local package to copy + Restart background server enter select · esc back diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index b9a876e408..4c329f3787 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -249,6 +249,7 @@ impl App { startup_bootstrap: Option, startup_hooks_browser: Option, daemon_startup_warning: Option, + managed_daemon: bool, mut startup_draft: StartupDraftPump, managed_worktree: Option, daemon_cli_executable: Option, @@ -385,10 +386,14 @@ impl App { } let mut model = startup_model(&config, &bootstrap, startup_defaults.server_defaults_read); let available_models = bootstrap.available_models; - let remote_connection = crate::status::remote_connection::remote_connection_status_value( - &app_server_target, - app_server.server_version(), - ); + let mut remote_connection = + crate::status::remote_connection::remote_connection_status_value( + &app_server_target, + app_server.server_version(), + ); + if let Some(connection) = &mut remote_connection { + connection.is_managed_daemon = connection.is_local_daemon && managed_daemon; + } let initial_server_version_notice = if !matches!(app_server_target, AppServerTarget::Embedded) { crate::status::remote_connection::pending_server_version_notice( @@ -940,6 +945,7 @@ See the Codex keymap documentation for supported actions and examples." }, daemon_cli_executable, pending_update_action: None, + daemon_restart: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState { prompt_after_trust: should_prompt_windows_sandbox_nux_at_startup @@ -1262,6 +1268,18 @@ See the Codex keymap documentation for supported actions and examples." .rate_limit_refresh_interval() .and_then(|interval| app.rate_limit_refresh_state.poll_deadline(interval)); let control = select! { + result = async { + match app.daemon_restart.as_mut() { + Some(task) => task.await, + None => std::future::pending().await, + } + }, if app.daemon_restart.is_some() => { + app.daemon_restart = None; + if let Err(err) = result.unwrap_or_else(|err| Err(err.into())) { + app.chat_widget.add_error_message(format!("Could not restart the background server: {err:#}")); + } + AppRunControl::Continue + } Some(event) = app_event_rx.recv() => { let is_initial_session_header = matches!( &event, @@ -1355,7 +1373,9 @@ See the Codex keymap documentation for supported actions and examples." reconnect = None; match result { Ok(connected) => { - app.finish_reconnect(tui, &mut app_server, &mut app_event_rx, connected, CODEX_CLI_VERSION).await?; + if let Err(err) = app.finish_reconnect(tui, &mut app_server, &mut app_event_rx, connected, CODEX_CLI_VERSION).await { + break Err(err); + } listen_for_app_server_events = true; waiting_for_initial_session_configured = false; } @@ -1452,6 +1472,11 @@ See the Codex keymap documentation for supported actions and examples." } let clear_pet_result = tui.clear_ambient_pet_image(); let clear_result = tui.terminal.clear(); + if let Some(task) = app.daemon_restart.take() + && let Err(err) = task.await.unwrap_or_else(|err| Err(err.into())) + { + tracing::warn!(error = %err, "failed to finish background server restart during exit"); + } // Keep the alternate screen active until the outer guard restores both keyboard stacks. let exit_reason = match exit_reason_result { Ok(exit_reason) => { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index ecbdb9cb7f..9394604dd2 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -83,6 +83,7 @@ pub(crate) async fn make_test_app() -> Box { reconnect: Default::default(), daemon_cli_executable: None, pending_update_action: None, + daemon_restart: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 89613d939c..f22d492847 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6258,6 +6258,7 @@ async fn make_test_app() -> Box { }, daemon_cli_executable: None, pending_update_action: None, + daemon_restart: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), @@ -6382,6 +6383,7 @@ pub(super) async fn make_test_app_with_channels() -> ( }, daemon_cli_executable: None, pending_update_action: None, + daemon_restart: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), 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 6f0fd8bdb4..86e0da6fd4 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -45,6 +45,7 @@ async fn run_startup_for_test( Some(bootstrap), /*startup_hooks_browser*/ None, /*daemon_startup_warning*/ None, + /*managed_daemon*/ false, crate::startup_draft::tests::quiet_startup_test_pump(), /*managed_worktree*/ None, /*daemon_cli_executable*/ None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 44f4e8a82a..5efebc9269 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -305,6 +305,8 @@ pub(crate) enum AppEvent { notice: crate::security_setup::Notice, }, OpenDaemonMenu, + ConfirmDaemonRestart, + RunDaemonRestart, ConfirmDaemonUpdate(crate::update_action::DaemonUpdateSource), RunDaemonUpdate(crate::update_action::DaemonUpdateSource), ReviewMisalignment(Arc), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fa7df2191c..5f99fdabe6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -3052,14 +3052,26 @@ mod tests { ] { let mut pane = test_pane(tx.clone()); match source { - "warning open" => pane.show_warnings(Vec::new(), Default::default()), + "warning open" => pane.show_warnings( + Vec::new(), + Default::default(), + /*daemon_restart_hint_at*/ None, + ), "warning navigation" => { - pane.show_warnings(Vec::new(), Default::default()); + pane.show_warnings( + Vec::new(), + Default::default(), + /*daemon_restart_hint_at*/ None, + ); pane.last_composer_activity_at = None; pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); } "warning paste" => { - pane.show_warnings(Vec::new(), Default::default()); + pane.show_warnings( + Vec::new(), + Default::default(), + /*daemon_restart_hint_at*/ None, + ); pane.last_composer_activity_at = None; pane.handle_paste("query".into()); } diff --git a/codex-rs/tui/src/bottom_pane/view_stack.rs b/codex-rs/tui/src/bottom_pane/view_stack.rs index 0f90ee53bb..fb1d91a126 100644 --- a/codex-rs/tui/src/bottom_pane/view_stack.rs +++ b/codex-rs/tui/src/bottom_pane/view_stack.rs @@ -7,6 +7,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crossterm::cursor::SetCursorStyle; use ratatui::buffer::Buffer; +use ratatui::buffer::CellDiffOption; use ratatui::layout::Rect; use ratatui::widgets::Clear; use ratatui::widgets::Widget; @@ -75,6 +76,13 @@ impl Renderable for CenteredView<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let dialog = dialog_area(self.0, area); Clear.render(dialog, buf); + // A dialog can cover cached blank cells whose color the terminal did not retain. + // Paint its whole surface; let wide text rendering still reset continuation cells. + for y in dialog.top()..dialog.bottom() { + for x in dialog.left()..dialog.right() { + buf[(x, y)].set_diff_option(CellDiffOption::AlwaysUpdate); + } + } self.0.render(dialog, buf); } diff --git a/codex-rs/tui/src/bottom_pane/warnings.rs b/codex-rs/tui/src/bottom_pane/warnings.rs index 522856b48e..31b371af18 100644 --- a/codex-rs/tui/src/bottom_pane/warnings.rs +++ b/codex-rs/tui/src/bottom_pane/warnings.rs @@ -5,13 +5,20 @@ use crate::history_cell::WarningEntry; use std::sync::Arc; impl BottomPane { - pub(crate) fn show_warnings(&mut self, entries: Vec, transcript: Arc<()>) { - self.warnings_view = Some(warnings_view::WarningsView::new( + pub(crate) fn show_warnings( + &mut self, + entries: Vec, + transcript: Arc<()>, + daemon_restart_hint_at: Option, + ) { + let mut view = warnings_view::WarningsView::new( entries, transcript, self.keymap.clone(), self.app_event_tx.clone(), - )); + ); + view.daemon_restart_hint_at = daemon_restart_hint_at; + self.warnings_view = Some(view); self.record_composer_activity_at(Instant::now()); self.request_redraw(); } diff --git a/codex-rs/tui/src/bottom_pane/warnings_view.rs b/codex-rs/tui/src/bottom_pane/warnings_view.rs index 3dfe27e406..3d3bb8966d 100644 --- a/codex-rs/tui/src/bottom_pane/warnings_view.rs +++ b/codex-rs/tui/src/bottom_pane/warnings_view.rs @@ -33,6 +33,8 @@ pub(super) struct WarningsView { tx: AppEventSender, pub(super) pending_hint: Option>, pub(super) flash: Option<(ratatui::text::Line<'static>, std::time::Instant)>, + /// Display only: copying or dismissing a warning must use its original diagnostic. + pub(super) daemon_restart_hint_at: Option, } impl WarningsView { @@ -55,6 +57,7 @@ impl WarningsView { tx, pending_hint: None, flash: None, + daemon_restart_hint_at: None, } } diff --git a/codex-rs/tui/src/bottom_pane/warnings_view_render.rs b/codex-rs/tui/src/bottom_pane/warnings_view_render.rs index 98557c86a5..171f88666c 100644 --- a/codex-rs/tui/src/bottom_pane/warnings_view_render.rs +++ b/codex-rs/tui/src/bottom_pane/warnings_view_render.rs @@ -38,6 +38,9 @@ impl Renderable for WarningsView { }; Line::from(title.bold()).render(header, buf); let details = entry.map_or("No warnings", |entry| entry.details.as_str()); + let restart_hint = (self.daemon_restart_hint_at == Some(self.current)).then_some( + "\nThese warnings may reflect settings from when the background server started. If you’ve already fixed them, use /daemon to restart it.", + ); if body.width > 0 && body.height > 0 && let Some(visited) = self.visited.get(self.current) @@ -46,6 +49,7 @@ impl Renderable for WarningsView { } let lines: Vec<_> = details .lines() + .chain(restart_hint.into_iter().flat_map(str::lines)) .flat_map(|text| { let source = crate::terminal_hyperlinks::annotate_web_urls_in_line(text.to_owned().into()); diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 8eeb1d24ce..8e83ad083a 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1147,30 +1147,74 @@ async fn sqlite_recovery_warning_shows_backup_and_metadata_limitations() { #[tokio::test] async fn startup_config_warning_is_not_repeated_by_thread() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let message = "Codex is ignoring 1 unrecognized configuration setting. Check for typos or deprecated settings."; - - for notification in [ - ServerNotification::ConfigWarning(ConfigWarningNotification { - summary: message.to_string(), - details: None, - path: None, - range: None, - }), - ServerNotification::Warning(WarningNotification { - thread_id: Some("thread-1".to_string()), - message: message.to_string(), - }), - ] { - chat.handle_server_notification(notification, /*replay_kind*/ None); + let mut warnings = Vec::new(); + for (local_daemon, managed_daemon) in [(false, false), (true, true), (true, false)] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + if local_daemon { + chat.remote_connection = + Some(crate::status::remote_connection::RemoteConnectionStatus { + address: "local".into(), + version: "v0.153.0".into(), + is_local_daemon: true, + is_managed_daemon: managed_daemon, + }); + } + let message = "Codex is ignoring 1 unrecognized configuration setting. Check for typos or deprecated settings."; + for notification in [ + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: message.to_string(), + details: None, + path: None, + range: None, + }), + ServerNotification::Warning(WarningNotification { + thread_id: Some("thread-1".to_string()), + message: message.to_string(), + }), + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "Second config warning".into(), + details: Some("A different setting needs attention.".into()), + path: None, + range: None, + }), + ] { + chat.handle_server_notification(notification, /*replay_kind*/ None); + } + let cells: Vec> = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(Arc::from(cell)), + _ => None, + }) + .collect(); + assert_eq!(cells.len(), 2); + let original_warnings = history_cell::warning_entries(&cells); + chat.open_warnings(&cells); + let first = super::helpers::render_bottom_popup(&chat, /*width*/ 80); + if local_daemon && !managed_daemon { + assert!( + !first.contains("/daemon"), + "unmanaged servers cannot be restarted" + ); + } else { + warnings.push(format!("local daemon: {local_daemon}\n{first}")); + } + chat.handle_key_event(KeyCode::Right.into()); + let second = super::helpers::render_bottom_popup(&chat, /*width*/ 80); + assert!(second.contains("Second config warning")); + assert!( + !second.contains("/daemon"), + "one restart hint per warning panel" + ); + chat.handle_key_event(KeyCode::Esc.into()); + let dismissed = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::UpdateWarnings { dismissed, .. } => Some(dismissed), + _ => None, + }) + .expect("closing the panel dismisses the viewed diagnostics"); + assert_eq!(dismissed, original_warnings); } - - let cells = drain_insert_history_transcript(&mut rx); - assert_eq!(cells.len(), 1); - insta::assert_snapshot!(lines_to_single_string(&cells[0]), @" - ⚠ Codex is ignoring 1 unrecognized configuration setting. Check for typos or - deprecated settings. - "); + insta::assert_snapshot!("startup_config_warning_panel", warnings.join("\n\n")); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs index a5aa8b9d83..7e136521f5 100644 --- a/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/misalignment_policy_tests.rs @@ -166,6 +166,7 @@ async fn misalignment_policy_failure_stops_the_thread_and_renders_once() { address: "wss://remote.example.com".to_string(), version: "v1.0.0".to_string(), is_local_daemon: false, + is_managed_daemon: false, }); chat.show_misalignment_policy_precaution(); assert_chatwidget_snapshot!( diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__startup_config_warning_panel.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__startup_config_warning_panel.snap new file mode 100644 index 0000000000..8a49a97bd9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__startup_config_warning_panel.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: "warnings.join(\"\\n\\n\")" +--- +local daemon: false + Warnings · 1 of 2 · Startup + + Codex is ignoring 1 unrecognized configuration setting. Check for typos or + deprecated settings. + + + + + + + + k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll + +local daemon: true + Warnings · 1 of 2 · Startup + + Codex is ignoring 1 unrecognized configuration setting. Check for typos or + deprecated settings. + + These warnings may reflect settings from when the background server started. + If you’ve already fixed them, use /daemon to restart it. + + + + + k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 75685a2581..fb7a21c25c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -533,6 +533,7 @@ async fn status_command_requests_thread_usage_for_remote_connection_metadata() { address: "wss://remote.example.com".to_string(), version: "v1.0.0".to_string(), is_local_daemon: false, + is_managed_daemon: false, }); chat.dispatch_command(SlashCommand::Status); diff --git a/codex-rs/tui/src/chatwidget/warnings.rs b/codex-rs/tui/src/chatwidget/warnings.rs index 580082e399..31c442a47f 100644 --- a/codex-rs/tui/src/chatwidget/warnings.rs +++ b/codex-rs/tui/src/chatwidget/warnings.rs @@ -53,9 +53,24 @@ fn fallback_model_metadata_warning_slug(message: &str) -> Option<&str> { impl super::ChatWidget { pub(crate) fn open_warnings(&mut self, cells: &[Arc]) { self.empty_state_animation.borrow_mut().pause_clock(); + let entries = self.warning_display_state.visible_entries(cells); + let daemon_restart_hint_at = self + .remote_connection + .as_ref() + .filter(|connection| connection.is_managed_daemon) + .and_then(|_| { + entries.iter().position(|entry| match &entry.id { + WarningId::Message(message) => self + .warning_display_state + .startup_config_warnings + .contains(message), + WarningId::McpServer(_) => false, + }) + }); self.bottom_pane.show_warnings( - self.warning_display_state.visible_entries(cells), + entries, Arc::clone(&self.warning_display_state.transcript), + daemon_restart_hint_at, ); } diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index cfa2d86233..29469b295e 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -46,6 +46,10 @@ pub struct Cli { #[clap(skip)] pub agents_overview: bool, + /// Internal: the launching CLI verified that the local daemon has a managed backend. + #[clap(skip)] + pub managed_daemon: bool, + // Internal controls set by the top-level `codex fork` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 476c5f4721..a3fb5447b0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1158,6 +1158,7 @@ async fn run_ratatui_app( embedded_network_policy: codex_app_server_client::EmbeddedNetworkPolicy, managed_worktree: Option, daemon_startup_warning: Option, + managed_daemon: bool, launch_telemetry: daemon_telemetry::Launch, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { @@ -2110,6 +2111,7 @@ async fn run_ratatui_app( startup_bootstrap, startup_hooks_browser, daemon_startup_warning, + managed_daemon, startup_draft, managed_worktree, daemon_cli_executable, diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 8f42ce5f54..c29d464adc 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -542,7 +542,7 @@ pub(super) async fn run_main_inner( let mut daemon_features = daemon_startup::server_features(&cli_kv_overrides); // Disabling shared services requires confirmation, even on a fresh auto-start. daemon_features.retain(|_, enabled| *enabled); - let mut managed_daemon = false; + let mut managed_daemon = cli.managed_daemon; if auto_start_daemon && daemon_exclusion.is_none() { let output = startup_draft .run_until(async { @@ -905,6 +905,7 @@ pub(super) async fn run_main_inner( embedded_network_policy, managed_worktree.clone(), daemon_startup_warning, + managed_daemon, launch_telemetry, startup_draft, )) diff --git a/codex-rs/tui/src/status/remote_connection.rs b/codex-rs/tui/src/status/remote_connection.rs index 6ed6fdd411..2ee0395438 100644 --- a/codex-rs/tui/src/status/remote_connection.rs +++ b/codex-rs/tui/src/status/remote_connection.rs @@ -10,6 +10,7 @@ pub(crate) struct RemoteConnectionStatus { pub(crate) address: String, pub(crate) version: String, pub(crate) is_local_daemon: bool, + pub(crate) is_managed_daemon: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -45,6 +46,7 @@ pub(crate) fn remote_connection_status_value( address, version, is_local_daemon: matches!(app_server_target, AppServerTarget::LocalDaemon { .. }), + is_managed_daemon: false, }) } @@ -193,6 +195,7 @@ mod tests { address: "ws://127.0.0.1:4500/".to_string(), version: "v1.2.3".to_string(), is_local_daemon: false, + is_managed_daemon: false, }) ); @@ -209,6 +212,7 @@ mod tests { address: format!("unix://{}", socket_path.display()), version: "unknown".to_string(), is_local_daemon: true, + is_managed_daemon: false, }) ); let remote_socket_target = AppServerTarget::Remote { @@ -222,6 +226,7 @@ mod tests { address: format!("unix://{}", socket_path.display()), version: "v1.2.3".to_string(), is_local_daemon: false, + is_managed_daemon: false, }) ); Ok(()) diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index c6e33d8fc8..d84b3d373f 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1728,6 +1728,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { .to_string(), version: "v0.133.0".to_string(), is_local_daemon, + is_managed_daemon: is_local_daemon, }); let model_slug = get_model_offline_for_tests(config.model.as_deref());