Files
codex/codex-rs/tui2/src/lib.rs
T
Josh McKinney b093565bfb WIP: Rework TUI viewport, history printing, and selection/copy (#7601)
> large behavior change to how the TUI owns its viewport, history, and
suspend behavior.
> Core model is in place; a few items are still being polished before
this is ready to merge.

We've moved this over to a new tui2 crate from being directly on the tui
crate.
To enable use --enable tui2 (or the equivalent in your config.toml). See
https://developers.openai.com/codex/local-config#feature-flags

Note that this serves as a baseline for the changes that we're making to
be applied rapidly. Tui2 may not track later changes in the main tui.
It's experimental and may not be where we land on things.

---

## Summary

This PR moves the Codex TUI off of “cooperating” with the terminal’s
scrollback and onto a model
where the in‑memory transcript is the single source of truth. The TUI
now owns scrolling, selection,
copy, and suspend/exit printing based on that transcript, and only
writes to terminal scrollback in
append‑only fashion on suspend/exit. It also fixes streaming wrapping so
streamed responses reflow
with the viewport, and introduces configuration to control whether we
print history on suspend or
only on exit.

High‑level goals:

- Ensure history is complete, ordered, and never silently dropped.
- Print each logical history cell at most once into scrollback, even
with resizes and suspends.
- Make scrolling, selection, and copy match the visible transcript, not
the terminal’s notion of
  scrollback.
- Keep suspend/alt‑screen behavior predictable across terminals.

---

## Core Design Changes

### Transcript & viewport ownership

- Treat the transcript as a list of **cells** (user prompts, agent
messages, system/info rows,
  streaming segments).
- On each frame:
- Compute a **transcript region** as “full terminal frame minus the
bottom input area”.
- Flatten all cells into visual lines plus metadata (which cell + which
line within that cell).
- Use scroll state to choose which visual line is at the top of the
region.
  - Clear that region and draw just the visible slice of lines.
- The terminal’s scrollback is no longer part of the live layout
algorithm; it is only ever written
  to when we decide to print history.

### User message styling

- User prompts now render as clear blocks with:
  - A blank padding line above and below.
- A full‑width background for every line in the block (including the
prompt line itself).
- The same block styling is used when we print history into scrollback,
so the transcript looks
consistent whether you are in the TUI or scrolling back after
exit/suspend.

---

## Scrolling, Mouse, Selection, and Copy

### Scrolling

- Scrolling is defined in terms of the flattened transcript lines:
  - Mouse wheel scrolls up/down by fixed line increments.
  - PgUp/PgDn/Home/End operate on the same scroll model.
- The footer shows:
  - Whether you are “following live output” vs “scrolled up”.
  - Current scroll position (line / total).
- When there is no history yet, the bottom pane is **pegged high** and
gradually moves down as the
  transcript fills, matching the existing UX.

### Selection

- Click‑and‑drag defines a **linear selection** over transcript
line/column coordinates, not raw
  screen rows.
- Selection is **content‑anchored**:
- When you scroll, the selection moves with the underlying lines instead
of sticking to a fixed
    Y position.
- This holds both when scrolling manually and when new content streams
in, as long as you are in
    “follow” mode.
- The selection only covers the “transcript text” area:
  - Left gutter/prefix (bullets, markers) is intentionally excluded.
- This keeps copy/paste cleaner and avoids including structural margin
characters.

### Copy (`Ctrl+Y`)

- Introduce a small clipboard abstraction (`ClipboardManager`‑style) and
use a cross‑platform
  clipboard crate under the hood.
- When `Ctrl+Y` is pressed and a non‑empty selection exists:
- Re‑render the transcript region off‑screen using the same wrapping as
the visible viewport.
- Walk the selected line/column range over that buffer to reconstruct
the exact text:
    - Includes spaces between words.
    - Preserves empty lines within the selection.
  - Send the resulting text to the system clipboard.
- Show a short status message in the footer indicating success/failure.
- Copy is **best‑effort**:
- Clipboard failures (headless environment, sandbox, remote sessions)
are handled gracefully via
    status messages; they do not crash the TUI.
- Copy does *not* insert a new history entry; it only affects the status
bar.

---

## Streaming and Wrapping

### Previous behavior

Previously, streamed markdown:

- Was wrapped at a fixed width **at commit time** inside the streaming
collector.
- Those wrapped `Line<'static>` values were then wrapped again at
display time.
- As a result, streamed paragraphs could not “un‑wrap” when the terminal
width increased; they were
  permanently split according to the width at the start of the stream.

### New behavior

This PR implements the first step from
`codex-rs/tui/streaming_wrapping_design.md`:

- Streaming collector is constructed **without** a fixed width for
wrapping.
  - It still:
    - Buffers the full markdown source for the current stream.
    - Commits only at newline boundaries.
    - Emits logical lines as new content becomes available.
- Agent message cells now wrap streamed content only at **display
time**, based on the current
  viewport width, just like non‑streaming messages.
- Consequences:
  - Streamed responses reflow correctly when the terminal is resized.
- Animation steps are per logical line instead of per “pre‑wrapped”
visual line; this makes some
commits slightly larger but keeps the behavior simple and predictable.

Streaming responses are still represented as a sequence of logical
history entries (first line +
continuations) and integrate with the same scrolling, selection, and
printing model.

---

## Printing History on Suspend and Exit

### High‑water mark and append‑only scrollback

- Introduce a **cell‑based high‑water mark** (`printed_history_cells`)
on the transcript:
- Represents “how many cells at the front of the transcript have already
been printed”.
  - Completely independent of wrapped line counts or terminal geometry.
- Whenever we print history (suspend or exit):
- Take the suffix of `transcript_cells` beyond `printed_history_cells`.
  - Render just that suffix into styled lines at the **current** width.
  - Write those lines to stdout.
  - Advance `printed_history_cells` to cover all cells we just printed.
- Older cells are never re‑rendered for scrollback. They stay in
whatever wrapping they had when
printed, which is acceptable as long as the logical content is present
once.

### Suspend (`Ctrl+Z`)

- On suspend:
  - Leave alt screen if active and restore normal terminal modes.
- Render the not‑yet‑printed suffix of the transcript and append it to
normal scrollback.
  - Advance the high‑water mark.
  - Suspend the process.
- On resume (`fg`):
  - Re‑enter the TUI mode (alt screen + input modes).
- Clear the viewport region and fully redraw from in‑memory transcript
and state.

This gives predictable behavior across terminals without trying to
maintain scrollback live.

### Exit

- On exit:
  - Render any remaining unprinted cells once and write them to stdout.
- Add an extra blank line after the final Codex history cell before
printing token usage, so the
    transcript and usage info are visually separated.
- If you never suspended, exit prints the entire transcript exactly
once.
- If you suspended one or more times, exit prints only the cells
appended after the last suspend.

---

## Configuration: Suspend Printing

This PR also adds configuration to control **when** we print history:

- New TUI config option to gate printing on suspend:
  - At minimum:
- `print_on_suspend = true` – current behavior: print new history at
each suspend *and* on exit.
    - `print_on_suspend = false` – only print on exit.
- Default is tuned to preserve current behavior, but this can be
revisited based on feedback.
- The config is respected in the suspend path:
- If disabled, suspend only restores terminal modes and stops rendering
but does not print new
    history.
  - Exit still prints the full not‑yet‑printed suffix once.

This keeps the core viewport logic agnostic to preference, while letting
users who care about
quiet scrollback opt out of suspend printing.

---

## Tradeoffs

What we gain:

- A single authoritative history model (the in‑memory transcript).
- Deterministic viewport rendering independent of terminal quirks.
- Suspend/exit flows that:
  - Print each logical history cell exactly once.
  - Work across resizes and different terminals.
  - Interact cleanly with alt screen and raw‑mode toggling.
- Consistent, content‑anchored scrolling, selection, and copy.
- Streaming messages that reflow correctly with the viewport width.

What we accept:

- Scrollback may contain older cells wrapped differently than newer
ones.
- Streaming responses appear in scrollback as a sequence of blocks
corresponding to their streaming
  structure, not as a single retroactively reflowed paragraph.
- We do not attempt to rewrite or reflow already‑printed scrollback.

For deeper rationale and diagrams, see
`docs/tui_viewport_and_history.md` and
`codex-rs/tui/streaming_wrapping_design.md`.

---

## Still to Do Before This PR Is Ready

These are scoped to this PR (not long‑term future work):

- [ ] **Streaming wrapping polish**
  - Double‑check all streaming paths use display‑time wrapping only.
  - Ensure tests cover resizing after streaming has started.

- [ ] **Suspend printing config**
- Finalize config shape and default (keep existing behavior vs opt‑out).
- Wire config through TUI startup and document it in the appropriate
config docs.

- [x] **Bottom pane positioning**
- Ensure the bottom pane is pegged high when there’s no history and
smoothly moves down as the
transcript fills, matching the current behavior across startup and
resume.

- [x] **Transcript mouse scrolling**
- Re‑enable wheel‑based transcript scrolling on top of the new scroll
model.
- Make sure mouse scroll does not get confused with “alternate scroll”
modes from terminals.

- [x] **Mouse selection vs streaming**
- When selection is active, stop auto‑scrolling on streaming so the
selection remains stable on
    the selected content.
- Ensure that when streaming continues after selection is cleared,
“follow latest output” mode
    resumes correctly.

- [ ] **Auto‑scroll during drag**
- While the user is dragging a selection, auto‑scroll when the cursor is
at/near the top or bottom
of the transcript viewport to allow selecting beyond the current visible
window.

- [ ] **Feature flag / rollout**
- Investigate gating the new viewport/history behavior behind a feature
flag for initial rollout,
so we can fall back to the old behavior if needed during early testing.

- [ ] **Before/after videos**
  - Capture short clips showing:
    - Scrolling (mouse + keys).
    - Selection and copy.
    - Streaming behavior under resize.
    - Suspend/resume and exit printing.
  - Use these to validate UX and share context in the PR discussion.
2025-12-15 17:20:53 -08:00

708 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Forbid accidental stdout/stderr writes in the *library* portion of the TUI.
// The standalone `codex-tui` binary prints a short help message before the
// alternatescreen mode starts; that file optsout locally via `allow`.
#![deny(clippy::print_stdout, clippy::print_stderr)]
#![deny(clippy::disallowed_methods)]
use additional_dirs::add_dir_warning_message;
use app::App;
pub use app::AppExitInfo;
use codex_app_server_protocol::AuthMode;
use codex_common::oss::ensure_oss_provider_ready;
use codex_common::oss::get_default_model_for_oss_provider;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::INTERACTIVE_SESSION_SOURCES;
use codex_core::RolloutRecorder;
use codex_core::auth::enforce_login_restrictions;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::resolve_oss_provider;
use codex_core::find_conversation_path_by_id_str;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::SandboxMode;
use std::fs::OpenOptions;
use std::path::PathBuf;
use tracing::error;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::filter::Targets;
use tracing_subscriber::prelude::*;
mod additional_dirs;
mod app;
mod app_backtrack;
mod app_event;
mod app_event_sender;
mod ascii_animation;
mod bottom_pane;
mod chatwidget;
mod cli;
mod clipboard_copy;
mod clipboard_paste;
mod color;
pub mod custom_terminal;
mod diff_render;
mod exec_cell;
mod exec_command;
mod file_search;
mod frames;
mod get_git_diff;
mod history_cell;
pub mod insert_history;
mod key_hint;
pub mod live_wrap;
mod markdown;
mod markdown_render;
mod markdown_stream;
mod model_migration;
mod notifications;
pub mod onboarding;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;
mod render;
mod resume_picker;
mod selection_list;
mod session_log;
mod shimmer;
mod skill_error_prompt;
mod slash_command;
mod status;
mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod text_formatting;
mod tooltips;
mod tui;
mod ui_consts;
pub mod update_action;
mod update_prompt;
mod updates;
mod version;
mod wrapping;
#[cfg(test)]
pub mod test_backend;
use crate::onboarding::TrustDirectorySelection;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::onboarding::onboarding_screen::run_onboarding_app;
use crate::tui::Tui;
pub use cli::Cli;
pub use markdown_render::render_markdown_text;
pub use public_widgets::composer_input::ComposerAction;
pub use public_widgets::composer_input::ComposerInput;
use std::io::Write as _;
// (tests access modules directly within the crate)
pub async fn run_main(
mut cli: Cli,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<AppExitInfo> {
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
Some(SandboxMode::WorkspaceWrite),
Some(AskForApproval::OnRequest),
)
} else if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
Some(AskForApproval::Never),
)
} else {
(
cli.sandbox_mode.map(Into::<SandboxMode>::into),
cli.approval_policy.map(Into::into),
)
};
// Map the legacy --search flag to the new feature toggle.
if cli.web_search {
cli.config_overrides
.raw_overrides
.push("features.web_search_request=true".to_string());
}
// When using `--oss`, let the bootstrapper pick the model (defaulting to
// gpt-oss:20b) and ensure it is present locally. Also, force the builtin
let raw_overrides = cli.config_overrides.raw_overrides.clone();
// `oss` model provider.
let overrides_cli = codex_common::CliConfigOverrides { raw_overrides };
let cli_kv_overrides = match overrides_cli.parse_overrides() {
// Parse `-c` overrides from the CLI.
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
// we load config.toml here to determine project state.
#[allow(clippy::print_stderr)]
let codex_home = match find_codex_home() {
Ok(codex_home) => codex_home.to_path_buf(),
Err(err) => {
eprintln!("Error finding codex home: {err}");
std::process::exit(1);
}
};
#[allow(clippy::print_stderr)]
let config_toml =
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides.clone()).await {
Ok(config_toml) => config_toml,
Err(err) => {
eprintln!("Error loading config.toml: {err}");
std::process::exit(1);
}
};
let model_provider_override = if cli.oss {
let resolved = resolve_oss_provider(
cli.oss_provider.as_deref(),
&config_toml,
cli.config_profile.clone(),
);
if let Some(provider) = resolved {
Some(provider)
} else {
// No provider configured, prompt the user
let provider = oss_selection::select_oss_provider(&codex_home).await?;
if provider == "__CANCELLED__" {
return Err(std::io::Error::other(
"OSS provider selection was cancelled by user",
));
}
Some(provider)
}
} else {
None
};
// When using `--oss`, let the bootstrapper pick the model based on selected provider
let model = if let Some(model) = &cli.model {
Some(model.clone())
} else if cli.oss {
// Use the provider from model_provider_override
model_provider_override
.as_ref()
.and_then(|provider_id| get_default_model_for_oss_provider(provider_id))
.map(std::borrow::ToOwned::to_owned)
} else {
None // No model specified, will use the default.
};
// canonicalize the cwd
let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p));
let additional_dirs = cli.add_dir.clone();
let overrides = ConfigOverrides {
model,
review_model: None,
approval_policy,
sandbox_mode,
cwd,
model_provider: model_provider_override.clone(),
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
developer_instructions: None,
compact_prompt: None,
include_apply_patch_tool: None,
show_raw_agent_reasoning: cli.oss.then_some(true),
tools_web_search_request: None,
additional_writable_roots: additional_dirs,
};
let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await;
if let Some(warning) = add_dir_warning_message(&cli.add_dir, &config.sandbox_policy) {
#[allow(clippy::print_stderr)]
{
eprintln!("Error adding directories: {warning}");
std::process::exit(1);
}
}
#[allow(clippy::print_stderr)]
if let Err(err) = enforce_login_restrictions(&config).await {
eprintln!("{err}");
std::process::exit(1);
}
let active_profile = config.active_profile.clone();
let log_dir = codex_core::config::log_dir(&config)?;
std::fs::create_dir_all(&log_dir)?;
// Open (or create) your log file, appending to it.
let mut log_file_opts = OpenOptions::new();
log_file_opts.create(true).append(true);
// Ensure the file is only readable and writable by the current user.
// Doing the equivalent to `chmod 600` on Windows is quite a bit more code
// and requires the Windows API crates, so we can reconsider that when
// Codex CLI is officially supported on Windows.
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
log_file_opts.mode(0o600);
}
let log_file = log_file_opts.open(log_dir.join("codex-tui.log"))?;
// Wrap file in nonblocking writer.
let (non_blocking, _guard) = non_blocking(log_file);
// use RUST_LOG env var, default to info for codex crates.
let env_filter = || {
EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info")
})
};
let file_layer = tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_target(false)
.with_ansi(false)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
.with_filter(env_filter());
let feedback = codex_feedback::CodexFeedback::new();
let targets = Targets::new().with_default(tracing::Level::TRACE);
let feedback_layer = tracing_subscriber::fmt::layer()
.with_writer(feedback.make_writer())
.with_ansi(false)
.with_target(false)
.with_filter(targets);
if cli.oss && model_provider_override.is_some() {
// We're in the oss section, so provider_id should be Some
// Let's handle None case gracefully though just in case
let provider_id = match model_provider_override.as_ref() {
Some(id) => id,
None => {
error!("OSS provider unexpectedly not set when oss flag is used");
return Err(std::io::Error::other(
"OSS provider not set but oss flag was used",
));
}
};
ensure_oss_provider_ready(provider_id, &config).await?;
}
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
#[allow(clippy::print_stderr)]
let otel = match otel {
Ok(otel) => otel,
Err(e) => {
eprintln!("Could not create otel exporter: {e}");
std::process::exit(1);
}
};
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
let _ = tracing_subscriber::registry()
.with(file_layer)
.with(feedback_layer)
.with(otel_tracing_layer)
.with(otel_logger_layer)
.try_init();
run_ratatui_app(
cli,
config,
overrides,
cli_kv_overrides,
active_profile,
feedback,
)
.await
.map_err(|err| std::io::Error::other(err.to_string()))
}
async fn run_ratatui_app(
cli: Cli,
initial_config: Config,
overrides: ConfigOverrides,
cli_kv_overrides: Vec<(String, toml::Value)>,
active_profile: Option<String>,
feedback: codex_feedback::CodexFeedback,
) -> color_eyre::Result<AppExitInfo> {
color_eyre::install()?;
// Forward panic reports through tracing so they appear in the UI status
// line, but do not swallow the default/color-eyre panic handler.
// Chain to the previous hook so users still get a rich panic report
// (including backtraces) after we restore the terminal.
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
tracing::error!("panic: {info}");
prev_hook(info);
}));
let mut terminal = tui::init()?;
terminal.clear()?;
let mut tui = Tui::new(terminal);
#[cfg(not(debug_assertions))]
{
use crate::update_prompt::UpdatePromptOutcome;
let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty());
if !skip_update_prompt {
match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? {
UpdatePromptOutcome::Continue => {}
UpdatePromptOutcome::RunUpdate(action) => {
crate::tui::restore()?;
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: Some(action),
session_lines: Vec::new(),
});
}
}
}
}
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config);
let auth_manager = AuthManager::shared(
initial_config.codex_home.clone(),
false,
initial_config.cli_auth_credentials_store_mode,
);
let login_status = get_login_status(&initial_config);
let should_show_trust_screen = should_show_trust_screen(&initial_config);
let should_show_onboarding =
should_show_onboarding(login_status, &initial_config, should_show_trust_screen);
let config = if should_show_onboarding {
let onboarding_result = run_onboarding_app(
OnboardingScreenArgs {
show_login_screen: should_show_login_screen(login_status, &initial_config),
show_trust_screen: should_show_trust_screen,
login_status,
auth_manager: auth_manager.clone(),
config: initial_config.clone(),
},
&mut tui,
)
.await?;
if onboarding_result.should_exit {
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
});
}
// if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly
if onboarding_result
.directory_trust_decision
.map(|d| d == TrustDirectorySelection::Trust)
.unwrap_or(false)
{
load_config_or_exit(cli_kv_overrides, overrides).await
} else {
initial_config
}
} else {
initial_config
};
// Determine resume behavior: explicit id, then resume last, then picker.
let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() {
match find_conversation_path_by_id_str(&config.codex_home, id_str).await? {
Some(path) => resume_picker::ResumeSelection::Resume(path),
None => {
error!("Error finding conversation path: {id_str}");
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
if let Err(err) = writeln!(
std::io::stdout(),
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
) {
error!("Failed to write resume error message: {err}");
}
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
});
}
}
} else if cli.resume_last {
let provider_filter = vec![config.model_provider_id.clone()];
match RolloutRecorder::list_conversations(
&config.codex_home,
1,
None,
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
&config.model_provider_id,
)
.await
{
Ok(page) => page
.items
.first()
.map(|it| resume_picker::ResumeSelection::Resume(it.path.clone()))
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
Err(_) => resume_picker::ResumeSelection::StartFresh,
}
} else if cli.resume_picker {
match resume_picker::run_resume_picker(
&mut tui,
&config.codex_home,
&config.model_provider_id,
cli.resume_show_all,
)
.await?
{
resume_picker::ResumeSelection::Exit => {
restore();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
session_lines: Vec::new(),
});
}
other => other,
}
} else {
resume_picker::ResumeSelection::StartFresh
};
let Cli { prompt, images, .. } = cli;
// Run the main chat + transcript UI on the terminal's alternate screen so
// the entire viewport can be used without polluting normal scrollback. This
// mirrors the behavior of the legacy TUI but keeps inline mode available
// for smaller prompts like onboarding and model migration.
let _ = tui.enter_alt_screen();
let app_result = App::run(
&mut tui,
auth_manager,
config,
active_profile,
prompt,
images,
resume_selection,
feedback,
should_show_trust_screen, // Proxy to: is it a first run in this directory?
)
.await;
let _ = tui.leave_alt_screen();
restore();
if let Ok(exit_info) = &app_result {
let mut stdout = std::io::stdout();
for line in exit_info.session_lines.iter() {
let _ = writeln!(stdout, "{line}");
}
if !exit_info.session_lines.is_empty() {
let _ = writeln!(stdout);
}
}
// Mark the end of the recorded session.
session_log::log_session_end();
// ignore error when collecting usage report underlying error instead
app_result
}
#[expect(
clippy::print_stderr,
reason = "TUI should no longer be displayed, so we can write to stderr."
)]
fn restore() {
if let Err(err) = tui::restore() {
eprintln!(
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginStatus {
AuthMode(AuthMode),
NotAuthenticated,
}
fn get_login_status(config: &Config) -> LoginStatus {
if config.model_provider.requires_openai_auth {
// Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
match CodexAuth::from_auth_storage(&codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
Ok(None) => LoginStatus::NotAuthenticated,
Err(err) => {
error!("Failed to read auth.json: {err}");
LoginStatus::NotAuthenticated
}
}
} else {
LoginStatus::NotAuthenticated
}
}
async fn load_config_or_exit(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
) -> Config {
#[allow(clippy::print_stderr)]
match Config::load_with_cli_overrides(cli_kv_overrides, overrides).await {
Ok(config) => config,
Err(err) => {
eprintln!("Error loading configuration: {err}");
std::process::exit(1);
}
}
}
/// Determine if user has configured a sandbox / approval policy,
/// or if the current cwd project is already trusted. If not, we need to
/// show the trust screen.
fn should_show_trust_screen(config: &Config) -> bool {
if cfg!(target_os = "windows") && get_platform_sandbox().is_none() {
// If the experimental sandbox is not enabled, Native Windows cannot enforce sandboxed write access; skip the trust prompt entirely.
return false;
}
if config.did_user_set_custom_approval_policy_or_sandbox_mode {
// Respect explicit approval/sandbox overrides made by the user.
return false;
}
// otherwise, show only if no trust decision has been made
config.active_project.trust_level.is_none()
}
fn should_show_onboarding(
login_status: LoginStatus,
config: &Config,
show_trust_screen: bool,
) -> bool {
if show_trust_screen {
return true;
}
should_show_login_screen(login_status, config)
}
fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool {
// Only show the login screen for providers that actually require OpenAI auth
// (OpenAI or equivalents). For OSS/other providers, skip login entirely.
if !config.model_provider.requires_openai_auth {
return false;
}
login_status == LoginStatus::NotAuthenticated
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::ProjectConfig;
use serial_test::serial;
use tempfile::TempDir;
#[test]
#[serial]
fn windows_skips_trust_prompt_without_sandbox() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
temp_dir.path().to_path_buf(),
)?;
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
config.active_project = ProjectConfig { trust_level: None };
config.set_windows_sandbox_globally(false);
let should_show = should_show_trust_screen(&config);
if cfg!(target_os = "windows") {
assert!(
!should_show,
"Windows trust prompt should always be skipped on native Windows"
);
} else {
assert!(
should_show,
"Non-Windows should still show trust prompt when project is untrusted"
);
}
Ok(())
}
#[test]
#[serial]
fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
temp_dir.path().to_path_buf(),
)?;
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
config.active_project = ProjectConfig { trust_level: None };
config.set_windows_sandbox_globally(true);
let should_show = should_show_trust_screen(&config);
if cfg!(target_os = "windows") {
assert!(
should_show,
"Windows trust prompt should be shown on native Windows with sandbox enabled"
);
} else {
assert!(
should_show,
"Non-Windows should still show trust prompt when project is untrusted"
);
}
Ok(())
}
#[test]
fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> {
use codex_protocol::config_types::TrustLevel;
let temp_dir = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
temp_dir.path().to_path_buf(),
)?;
config.did_user_set_custom_approval_policy_or_sandbox_mode = false;
config.active_project = ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
};
let should_show = should_show_trust_screen(&config);
assert!(
!should_show,
"Trust prompt should not be shown for projects explicitly marked as untrusted"
);
Ok(())
}
}