Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/config/src/loader/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
}
}

pub(super) fn has_managed_preferences() -> io::Result<bool> {
Ok(
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?.is_some()
|| load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?.is_some(),
)
}

pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
strict_config: bool,
Expand Down
19 changes: 19 additions & 0 deletions codex-rs/config/src/loader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,25 @@ fn system_requirements_toml_file_with_overrides(
}
}

/// Check local managed configuration sources without loading or parsing configuration.
///
/// Filesystem or managed-preference errors are returned so callers can conservatively avoid
/// assuming that administrator-controlled configuration is absent.
pub fn has_local_managed_configuration(codex_home: &Path) -> io::Result<bool> {
if layer_io::managed_config_default_path(codex_home).try_exists()?
|| system_requirements_toml_file()?.as_path().try_exists()?
{
return Ok(true);
}

#[cfg(target_os = "macos")]
if macos::has_managed_preferences()? {
return Ok(true);
}

Ok(false)
}

#[cfg(unix)]
pub fn system_config_toml_file() -> io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX))
Expand Down
7 changes: 7 additions & 0 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ mod startup_draft;
mod startup_error;
mod startup_hooks_review;
mod startup_orchestration;
mod startup_preflight;
mod status;
mod status_indicator_widget;
mod streaming;
Expand Down Expand Up @@ -1194,6 +1195,12 @@ async fn run_ratatui_app(
initial_config
};
startup_draft.apply_config(&config);
if !(cli.resume_picker || cli.fork_picker)
&& let Err(err) = startup_draft.show(&mut tui)
{
shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await;
return Err(err.into());
}

let missing_session_exit =
|id_str: &str,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
source: tui/src/startup_draft_tests.rs
expression: frames
---
before onboarding: hidden (0x0 viewport)
---
after onboarding:
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: loading /model to change │
│ directory: loading │
╰───────────────────────────────────────╯


› Ask Codex to do anything

? for shortcuts
17 changes: 12 additions & 5 deletions codex-rs/tui/src/startup_draft.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const STARTUP_PASTE_NEWLINE_TIMEOUT: Duration = Duration::from_millis(120);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StartupDraftInitialScreen {
Composer,
Onboarding,
SessionPicker,
}

Expand Down Expand Up @@ -115,9 +116,7 @@ impl StartupDraft {
pending_paste_newline: None,
},
};
if initial_screen == StartupDraftInitialScreen::Composer {
draft.pump.show(&mut draft.tui)?;
}
draft.pump.show_initial_screen(&mut draft.tui)?;
Ok(draft)
}

Expand Down Expand Up @@ -230,7 +229,15 @@ impl StartupDraftPump {
self.bottom_pane.composer_draft_snapshot()
}

/// Reveal the editable composer once a requested session picker has finished.
/// Draw the initial composer only when no protected startup screen must appear first.
fn show_initial_screen(&mut self, tui: &mut Tui) -> io::Result<()> {
if self.initial_screen == StartupDraftInitialScreen::Composer {
self.show(tui)?;
}
Ok(())
}

/// Reveal the editable composer once an expected protected screen has finished.
pub(crate) fn show(&mut self, tui: &mut Tui) -> io::Result<()> {
self.initial_screen = StartupDraftInitialScreen::Composer;
self.draw(tui, tui.terminal.last_known_screen_size)
Expand Down Expand Up @@ -274,7 +281,7 @@ impl StartupDraftPump {
}
match event {
TuiEvent::Key(key) => {
if self.initial_screen == StartupDraftInitialScreen::SessionPicker
if self.initial_screen != StartupDraftInitialScreen::Composer
&& !key_hint::ctrl(KeyCode::Char('c')).is_press(key)
&& !key_hint::ctrl(KeyCode::Char('d')).is_press(key)
{
Expand Down
84 changes: 84 additions & 0 deletions codex-rs/tui/src/startup_draft_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,90 @@ async fn startup_draft_applies_editor_keymap_without_enabling_vim() {
assert_eq!(pump.bottom_pane.composer_cursor(), 0);
}

#[tokio::test]
async fn startup_draft_waits_for_onboarding_before_accepting_input() {
let mut composer_pump = startup_test_pump(std::iter::empty());
let mut composer_tui =
crate::tui::test_support::make_test_tui().expect("create composer test terminal");
composer_pump
.show_initial_screen(&mut composer_tui)
.expect("draw the composer when no protected screen is expected");
assert!(!composer_tui.terminal.viewport_area.is_empty());
drop(composer_tui);

let mut pump = startup_test_pump(
[
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
TuiEvent::Paste("not a draft".to_string()),
TuiEvent::Draw,
]
.into_iter(),
);
pump.initial_screen = StartupDraftInitialScreen::Onboarding;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");
pump.show_initial_screen(&mut tui)
.expect("keep the composer hidden until onboarding finishes");

pump.flush_pending_events(&mut tui)
.await
.expect("ignore input before onboarding owns the screen");
assert!(pump.bottom_pane.composer_is_empty());
assert!(tui.terminal.viewport_area.is_empty());
let hidden_area = tui.terminal.viewport_area;
let mut frames = format!(
"before onboarding: hidden ({}x{} viewport)",
hidden_area.width, hidden_area.height
);

pump.show(&mut tui)
.expect("show the composer after onboarding finishes");
assert!(!tui.terminal.viewport_area.is_empty());
let area = tui.terminal.viewport_area;
let renderable = startup_draft_renderable(&pump.header, &pump.bottom_pane);
let mut buffer = Buffer::empty(area);
renderable.render(area, &mut buffer);
let visible_frame = (area.top()..area.bottom())
.map(|row| {
(area.left()..area.right())
.map(|column| buffer[(column, row)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
.replace(crate::version::CODEX_CLI_VERSION, "<VERSION>");
drop(renderable);
frames.push_str(&format!("\n---\nafter onboarding:\n{visible_frame}"));
insta::assert_snapshot!("startup_draft_onboarding_transition", frames);

pump.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
)
.expect("edit the composer after onboarding finishes");
pump.bottom_pane.flush_composer_paste_burst();
assert_eq!(pump.bottom_pane.composer_text(), "y");
}

#[tokio::test]
async fn startup_draft_allows_cancellation_before_onboarding_appears() {
for character in ['c', 'd'] {
let mut pump = startup_test_pump(std::iter::once(TuiEvent::Key(KeyEvent::new(
KeyCode::Char(character),
KeyModifiers::CONTROL,
))));
pump.initial_screen = StartupDraftInitialScreen::Onboarding;
let mut tui = crate::tui::test_support::make_test_tui().expect("create test terminal");

let error = pump
.flush_pending_events(&mut tui)
.await
.expect_err("cancel startup before onboarding appears");
assert!(super::StartupCancelled::matches(&error));
}
}

#[tokio::test]
async fn startup_draft_waits_for_session_picker_before_accepting_input() {
let mut pump = startup_test_pump(
Expand Down
26 changes: 19 additions & 7 deletions codex-rs/tui/src/startup_orchestration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,20 +135,32 @@ pub(super) async fn run_main_inner(
.await;
}

let initial_screen = if cli.resume_picker || cli.fork_picker {
startup_draft::StartupDraftInitialScreen::SessionPicker
} else {
startup_draft::StartupDraftInitialScreen::Composer
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;

let reuse_implicit_local_daemon = !workload_identity_selected
&& can_reuse_implicit_local_daemon(
&cli_kv_overrides,
&launch_loader_overrides,
strict_config,
cli.bypass_hook_trust,
);
let initial_screen = if cli.resume_picker || cli.fork_picker {
startup_draft::StartupDraftInitialScreen::SessionPicker
} else if !cli.oss
&& explicit_remote_endpoint.is_none()
&& reuse_implicit_local_daemon
&& launch_loader_overrides.packaged_defaults_path.is_none()
&& startup_preflight::should_delay_startup_composer_for_first_login(
&codex_home,
codex_config::loader::system_config_toml_file(),
|| codex_config::loader::has_local_managed_configuration(&codex_home),
|name| std::env::var(name).ok(),
)
{
startup_draft::StartupDraftInitialScreen::Onboarding
} else {
startup_draft::StartupDraftInitialScreen::Composer
};
let mut startup_draft = startup_draft::StartupDraft::new(initial_screen)?;

let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
startup_draft
.run_until(maybe_probe_default_daemon_socket(&codex_home))
Expand Down
69 changes: 69 additions & 0 deletions codex-rs/tui/src/startup_preflight.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Conservative first-install checks that run before the provisional composer appears.
//!
//! Any existing user, system, daemon, or authentication state keeps the composer visible.

use std::io;
use std::path::Path;

use codex_utils_absolute_path::AbsolutePathBuf;

/// Hide the composer only on a first installation without user or machine-wide configuration.
pub(super) fn should_delay_startup_composer_for_first_login(
codex_home: &Path,
system_config_path: io::Result<AbsolutePathBuf>,
managed_configuration: impl FnOnce() -> io::Result<bool>,
environment_variable: impl Fn(&str) -> Option<String>,
) -> bool {
if environment_variable("CODEX_HOME").is_some_and(|value| !value.is_empty())
|| environment_variable(codex_login::CODEX_ACCESS_TOKEN_ENV_VAR)
.is_some_and(|credential| !credential.trim().is_empty())
{
return false;
}

let Ok(system_config_path) = system_config_path else {
return false;
};
if !matches!(system_config_path.as_path().try_exists(), Ok(false)) {
return false;
}

let pristine_home = match codex_home.try_exists() {
Ok(false) => true,
Err(_) => false,
Ok(true) => {
let Ok(mut entries) = std::fs::read_dir(codex_home) else {
return false;
};
let Some(Ok(temporary_root)) = entries.next() else {
return false;
};
if temporary_root.file_name() != "tmp"
|| !temporary_root
.file_type()
.is_ok_and(|file_type| file_type.is_dir())
|| entries.next().is_some()
{
return false;
}

let Ok(mut entries) = std::fs::read_dir(temporary_root.path()) else {
return false;
};
let Some(Ok(arg0_root)) = entries.next() else {
return false;
};
arg0_root.file_name() == "arg0"
&& arg0_root
.file_type()
.is_ok_and(|file_type| file_type.is_dir())
&& entries.next().is_none()
}
};

pristine_home && matches!(managed_configuration(), Ok(false))
}

#[cfg(test)]
#[path = "startup_preflight_tests.rs"]
mod tests;
Loading
Loading