From 91bc7fc5fdcdbeb94bc888f9179d4bc70db58d8b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 5 Jan 2026 19:38:23 -0800 Subject: [PATCH] tui2 --- codex-rs/protocol/src/config.rs | 778 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 33 +- ...et__tests__startup_header_active_cell.snap | 20 + codex-rs/tui/src/chatwidget/tests.rs | 67 ++ codex-rs/tui/src/history_cell.rs | 44 +- codex-rs/tui2/src/chatwidget.rs | 29 +- ...et__tests__startup_header_active_cell.snap | 11 + codex-rs/tui2/src/chatwidget/tests.rs | 57 ++ codex-rs/tui2/src/history_cell.rs | 44 +- 9 files changed, 1075 insertions(+), 8 deletions(-) create mode 100644 codex-rs/protocol/src/config.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_active_cell.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__startup_header_active_cell.snap diff --git a/codex-rs/protocol/src/config.rs b/codex-rs/protocol/src/config.rs new file mode 100644 index 0000000000..39495b3884 --- /dev/null +++ b/codex-rs/protocol/src/config.rs @@ -0,0 +1,778 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigProfile; +use codex_core::config::ConfigToml; +use codex_core::config::Constrained; +use codex_core::config::FeatureOverrides; +use codex_core::config::ProjectConfig; +use codex_core::config::SandboxPolicyResolution; +use codex_core::config::types::DEFAULT_OTEL_ENVIRONMENT; +use codex_core::config::types::History; +use codex_core::config::types::McpServerConfig; +use codex_core::config::types::Notice; +use codex_core::config::types::Notifications; +use codex_core::config::types::OtelConfig; +use codex_core::config::types::OtelConfigToml; +use codex_core::config::types::OtelExporterKind; +use codex_core::config::types::ScrollInputMode; +use codex_core::config::types::ShellEnvironmentPolicy; +use codex_core::config::types::UriBasedFileOpener; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigRequirements; +use codex_core::features::Feature; +use codex_core::features::Features; +use codex_core::model_provider_info::ModelProviderInfo; +use codex_core::model_provider_info::built_in_model_providers; +use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; +use codex_core::project_doc::LOCAL_PROJECT_DOC_FILENAME; +use codex_git::GhostSnapshotConfig; +use codex_utils_absolute_path::AbsolutePathBuf; +use toml::Value as TomlValue; + +use crate::config_types::AuthCredentialsStoreMode; +use crate::config_types::ForcedLoginMethod; +use crate::config_types::OAuthCredentialsStoreMode; +use crate::config_types::ReasoningSummary; +use crate::config_types::Verbosity; +use crate::openai_models::ReasoningEffort; +use crate::openai_models::ReasoningSummaryFormat; +use crate::protocol::AskForApproval; +use crate::protocol::SandboxPolicy; + +/// Application configuration loaded from disk and merged with overrides. +#[derive(Debug, Clone, PartialEq)] +pub struct Config { + /// Provenance for how this [`Config`] was derived (merged layers + enforced + /// requirements). + pub config_layer_stack: ConfigLayerStack, + + /// Optional override of model selection. + pub model: Option, + + /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". + pub review_model: String, + + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Token usage threshold triggering auto-compaction of conversation history. + pub model_auto_compact_token_limit: Option, + + /// Key into the model_providers map that specifies which provider to use. + pub model_provider_id: String, + + /// Info needed to make an API request to the model. + pub model_provider: ModelProviderInfo, + + /// Approval policy for executing commands. + pub approval_policy: Constrained, + + pub sandbox_policy: Constrained, + + /// True if the user passed in an override or set a value in config.toml + /// for either of approval_policy or sandbox_mode. + pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, + + /// On Windows, indicates that a previously configured workspace-write sandbox + /// was coerced to read-only because native auto mode is unsupported. + pub forced_auto_mode_downgraded_on_windows: bool, + + pub shell_environment_policy: ShellEnvironmentPolicy, + + /// When `true`, `AgentReasoning` events emitted by the backend will be + /// suppressed from the frontend output. This can reduce visual noise when + /// users are only interested in the final agent responses. + pub hide_agent_reasoning: bool, + + /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. + /// Defaults to `false`. + pub show_raw_agent_reasoning: bool, + + /// User-provided instructions from AGENTS.md. + pub user_instructions: Option, + + /// Base instructions override. + pub base_instructions: Option, + + /// Developer instructions override injected as a separate message. + pub developer_instructions: Option, + + /// Compact prompt override. + pub compact_prompt: Option, + + /// Optional external notifier command. When set, Codex will spawn this + /// program after each completed *turn* (i.e. when the agent finishes + /// processing a user submission). The value must be the full command + /// broken into argv tokens **without** the trailing JSON argument - Codex + /// appends one extra argument containing a JSON payload describing the + /// event. + /// + /// Example `~/.codex/config.toml` snippet: + /// + /// ```toml + /// notify = ["notify-send", "Codex"] + /// ``` + /// + /// which will be invoked as: + /// + /// ```shell + /// notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345"}' + /// ``` + /// + /// If unset the feature is disabled. + pub notify: Option>, + + /// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals + /// and turn completions when not focused. + pub tui_notifications: Notifications, + + /// Enable ASCII animations and shimmer effects in the TUI. + pub animations: bool, + + /// Show startup tooltips in the TUI welcome screen. + pub show_tooltips: bool, + + /// Override the events-per-wheel-tick factor for TUI2 scroll normalization. + /// + /// This is the same `tui.scroll_events_per_tick` value from `config.toml`, plumbed through the + /// merged [`Config`] object (see [`Tui`]) so TUI2 can normalize scroll event density per + /// terminal. + pub tui_scroll_events_per_tick: Option, + + /// Override the number of lines applied per wheel tick in TUI2. + /// + /// This is the same `tui.scroll_wheel_lines` value from `config.toml` (see [`Tui`]). TUI2 + /// applies it to wheel-like scroll streams. Trackpad-like scrolling uses a separate + /// `tui.scroll_trackpad_lines` setting. + pub tui_scroll_wheel_lines: Option, + + /// Override the number of lines per tick-equivalent used for trackpad scrolling in TUI2. + /// + /// This is the same `tui.scroll_trackpad_lines` value from `config.toml` (see [`Tui`]). + pub tui_scroll_trackpad_lines: Option, + + /// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2. + /// + /// This is the same `tui.scroll_trackpad_accel_events` value from `config.toml` (see [`Tui`]). + pub tui_scroll_trackpad_accel_events: Option, + + /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams in TUI2. + /// + /// This is the same `tui.scroll_trackpad_accel_max` value from `config.toml` (see [`Tui`]). + pub tui_scroll_trackpad_accel_max: Option, + + /// Control how TUI2 interprets mouse scroll input (wheel vs trackpad). + /// + /// This is the same `tui.scroll_mode` value from `config.toml` (see [`Tui`]). + pub tui_scroll_mode: ScrollInputMode, + + /// Override the wheel tick detection threshold (ms) for TUI2 auto scroll mode. + /// + /// This is the same `tui.scroll_wheel_tick_detect_max_ms` value from `config.toml` (see + /// [`Tui`]). + pub tui_scroll_wheel_tick_detect_max_ms: Option, + + /// Override the wheel-like end-of-stream threshold (ms) for TUI2 auto scroll mode. + /// + /// This is the same `tui.scroll_wheel_like_max_duration_ms` value from `config.toml` (see + /// [`Tui`]). + pub tui_scroll_wheel_like_max_duration_ms: Option, + + /// Invert mouse scroll direction for TUI2. + /// + /// This is the same `tui.scroll_invert` value from `config.toml` (see [`Tui`]) and is applied + /// consistently to both mouse wheels and trackpads. + pub tui_scroll_invert: bool, + + /// The directory that should be treated as the current working directory + /// for the session. All relative paths inside the business-logic layer are + /// resolved against this path. + pub cwd: PathBuf, + + /// Preferred store for CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the OS-specific keyring service if available, otherwise use a file. + pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + + /// Definition for MCP servers that Codex can reach out to for tool calls. + pub mcp_servers: HashMap, + + /// Preferred store for MCP OAuth credentials. + /// keyring: Use an OS-specific keyring service. + /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. + /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// file: CODEX_HOME/.credentials.json + /// This file will be readable to Codex and other applications running as the same user. + /// auto (default): keyring if available, otherwise file. + pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode, + + /// Combined provider map (defaults merged with user-defined overrides). + pub model_providers: HashMap, + + /// Maximum number of bytes to include from an AGENTS.md project doc file. + pub project_doc_max_bytes: usize, + + /// Additional filenames to try when looking for project-level docs. + pub project_doc_fallback_filenames: Vec, + + // todo(aibrahim): this should be used in the override model family + /// Token budget applied when storing tool/function outputs in the context manager. + pub tool_output_token_limit: Option, + + /// Directory containing all Codex state (defaults to `~/.codex` but can be + /// overridden by the `CODEX_HOME` environment variable). + pub codex_home: PathBuf, + + /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. + pub history: History, + + /// Optional URI-based file opener. If set, citations to files in the model + /// output will be hyperlinked using the specified URI scheme. + pub file_opener: UriBasedFileOpener, + + /// Path to the `codex-linux-sandbox` executable. This must be set if + /// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this + /// cannot be set in the config file: it must be set in code via + /// [`ConfigOverrides`]. + /// + /// When this program is invoked, arg0 will be set to `codex-linux-sandbox`. + pub codex_linux_sandbox_exe: Option, + + /// Value to use for `reasoning.effort` when making a request using the + /// Responses API. + pub model_reasoning_effort: Option, + + /// If not "none", the value to use for `reasoning.summary` when making a + /// request using the Responses API. + pub model_reasoning_summary: ReasoningSummary, + + /// Optional override to force-enable reasoning summaries for the configured model. + pub model_supports_reasoning_summaries: Option, + + /// Optional override to force reasoning summary format for the configured model. + pub model_reasoning_summary_format: Option, + + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). + pub model_verbosity: Option, + + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). + pub chatgpt_base_url: String, + + /// When set, restricts ChatGPT login to a specific workspace identifier. + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + pub forced_login_method: Option, + + /// Include the `apply_patch` tool for models that benefit from invoking + /// file edits as a structured tool call. When unset, this falls back to the + /// model family's default preference. + pub include_apply_patch_tool: bool, + + pub tools_web_search_request: bool, + + /// If set to `true`, used only the experimental unified exec tool. + pub use_experimental_unified_exec_tool: bool, + + /// Settings for ghost snapshots (used for undo). + pub ghost_snapshot: GhostSnapshotConfig, + + /// Centralized feature flags; source of truth for feature gating. + pub features: Features, + + /// The active profile name used to derive this `Config` (if any). + pub active_profile: Option, + + /// The currently active project config, resolved by checking if cwd: + /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd + pub active_project: ProjectConfig, + + /// Tracks whether the Windows onboarding screen has been acknowledged. + pub windows_wsl_setup_acknowledged: bool, + + /// Collection of various notices we show the user + pub notices: Notice, + + /// When `true`, checks for Codex updates on startup and surfaces update prompts. + /// Set to `false` only if your Codex updates are centrally managed. + /// Defaults to `true`. + pub check_for_update_on_startup: bool, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: bool, + + /// OTEL configuration (exporter type, endpoint, headers, etc.). + pub otel: OtelConfig, +} + +impl Config { + /// This is the preferred way to create an instance of [Config]. + pub async fn load_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, + ) -> std::io::Result { + ConfigBuilder::default() + .cli_overrides(cli_overrides) + .build() + .await + } + + /// This is a secondary way of creating [Config], which is appropriate when + /// the harness is meant to be used with a specific configuration that + /// ignores user settings. For example, the `codex exec` subcommand is + /// designed to use [AskForApproval::Never] exclusively. + /// + /// Further, [ConfigOverrides] contains some options that are not supported + /// in [ConfigToml], such as `cwd` and `codex_linux_sandbox_exe`. + pub async fn load_with_cli_overrides_and_harness_overrides( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + ) -> std::io::Result { + ConfigBuilder::default() + .cli_overrides(cli_overrides) + .harness_overrides(harness_overrides) + .build() + .await + } +} + +impl Config { + #[cfg(test)] + fn load_from_base_config_with_overrides( + cfg: ConfigToml, + overrides: ConfigOverrides, + codex_home: PathBuf, + ) -> std::io::Result { + // Note this ignores requirements.toml enforcement for tests. + let config_layer_stack = ConfigLayerStack::default(); + Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack) + } + + fn load_config_with_layer_stack( + cfg: ConfigToml, + overrides: ConfigOverrides, + codex_home: PathBuf, + config_layer_stack: ConfigLayerStack, + ) -> std::io::Result { + let requirements = config_layer_stack.requirements().clone(); + let user_instructions = Self::load_instructions(Some(&codex_home)); + + // Destructure ConfigOverrides fully to ensure all overrides are applied. + let ConfigOverrides { + model, + review_model: override_review_model, + cwd, + approval_policy: approval_policy_override, + sandbox_mode, + model_provider, + config_profile: config_profile_key, + codex_linux_sandbox_exe, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool: include_apply_patch_tool_override, + show_raw_agent_reasoning, + tools_web_search_request: override_tools_web_search_request, + additional_writable_roots, + } = overrides; + + let active_profile_name = config_profile_key + .as_ref() + .or(cfg.profile.as_ref()) + .cloned(); + let config_profile = match active_profile_name.as_ref() { + Some(key) => cfg + .profiles + .get(key) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("config profile `{key}` not found"), + ) + })? + .clone(), + None => ConfigProfile::default(), + }; + + let feature_overrides = FeatureOverrides { + include_apply_patch_tool: include_apply_patch_tool_override, + web_search_request: override_tools_web_search_request, + }; + + let features = Features::from_config(&cfg, &config_profile, feature_overrides); + #[cfg(target_os = "windows")] + { + // Base flag controls sandbox on/off; elevated only applies when base is enabled. + let sandbox_enabled = features.enabled(Feature::WindowsSandbox); + codex_core::safety::set_windows_sandbox_enabled(sandbox_enabled); + let elevated_enabled = + sandbox_enabled && features.enabled(Feature::WindowsSandboxElevated); + codex_core::safety::set_windows_elevated_sandbox_enabled(elevated_enabled); + } + + let resolved_cwd = { + use std::env; + + match cwd { + None => { + tracing::info!("cwd not set, using current dir"); + env::current_dir()? + } + Some(p) if p.is_absolute() => p, + Some(p) => { + // Resolve relative path against the current working directory. + tracing::info!("cwd is relative, resolving against current dir"); + let mut current = env::current_dir()?; + current.push(p); + current + } + } + }; + let additional_writable_roots: Vec = additional_writable_roots + .into_iter() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd)) + .collect::, _>>()?; + let active_project = cfg + .get_active_project(&resolved_cwd) + .unwrap_or(ProjectConfig { trust_level: None }); + + let SandboxPolicyResolution { + policy: mut sandbox_policy, + forced_auto_mode_downgraded_on_windows, + } = cfg.derive_sandbox_policy(sandbox_mode, config_profile.sandbox_mode, &resolved_cwd); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { + for path in additional_writable_roots { + if !writable_roots.iter().any(|existing| existing == &path) { + writable_roots.push(path); + } + } + } + let approval_policy = approval_policy_override + .or(config_profile.approval_policy) + .or(cfg.approval_policy) + .unwrap_or_else(|| { + if active_project.is_trusted() { + AskForApproval::OnRequest + } else if active_project.is_untrusted() { + AskForApproval::UnlessTrusted + } else { + AskForApproval::default() + } + }); + // TODO(dylan): We should be able to leverage ConfigLayerStack so that + // we can reliably check this at every config level. + let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override + .is_some() + || config_profile.approval_policy.is_some() + || cfg.approval_policy.is_some() + || sandbox_mode.is_some() + || config_profile.sandbox_mode.is_some() + || cfg.sandbox_mode.is_some(); + + let mut model_providers = built_in_model_providers(); + // Merge user-defined providers into the built-in list. + for (key, provider) in cfg.model_providers.into_iter() { + model_providers.entry(key).or_insert(provider); + } + + let model_provider_id = model_provider + .or(config_profile.model_provider) + .or(cfg.model_provider) + .unwrap_or_else(|| "openai".to_string()); + let model_provider = model_providers + .get(&model_provider_id) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Model provider `{model_provider_id}` not found"), + ) + })? + .clone(); + + let shell_environment_policy = cfg.shell_environment_policy.into(); + + let history = cfg.history.unwrap_or_default(); + + let ghost_snapshot = { + let mut config = GhostSnapshotConfig::default(); + if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() + && let Some(ignore_over_bytes) = ghost_snapshot.ignore_large_untracked_files + { + config.ignore_large_untracked_files = if ignore_over_bytes > 0 { + Some(ignore_over_bytes) + } else { + None + }; + } + if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() + && let Some(threshold) = ghost_snapshot.ignore_large_untracked_dirs + { + config.ignore_large_untracked_dirs = + if threshold > 0 { Some(threshold) } else { None }; + } + if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() + && let Some(disable_warnings) = ghost_snapshot.disable_warnings + { + config.disable_warnings = disable_warnings; + } + config + }; + + let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); + let tools_web_search_request = features.enabled(Feature::WebSearchRequest); + let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); + + let forced_chatgpt_workspace_id = + cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + let forced_login_method = cfg.forced_login_method; + + let model = model.or(config_profile.model).or(cfg.model); + + let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + // Load base instructions override from a file if specified. If the + // path is relative, resolve it against the effective cwd so the + // behaviour matches other path-like config values. + let experimental_instructions_path = config_profile + .experimental_instructions_file + .as_ref() + .or(cfg.experimental_instructions_file.as_ref()); + let file_base_instructions = Self::try_read_non_empty_file( + experimental_instructions_path, + "experimental instructions file", + )?; + let base_instructions = base_instructions.or(file_base_instructions); + let developer_instructions = developer_instructions.or(cfg.developer_instructions); + + let experimental_compact_prompt_path = config_profile + .experimental_compact_prompt_file + .as_ref() + .or(cfg.experimental_compact_prompt_file.as_ref()); + let file_compact_prompt = Self::try_read_non_empty_file( + experimental_compact_prompt_path, + "experimental compact prompt file", + )?; + let compact_prompt = compact_prompt.or(file_compact_prompt); + + // Default review model when not set in config; allow CLI override to take precedence. + let review_model = override_review_model + .or(cfg.review_model) + .unwrap_or_else(|| "gpt-5.1-codex-max".to_string()); + + let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); + + // Ensure that every field of ConfigRequirements is applied to the final + // Config. + let ConfigRequirements { + approval_policy: mut constrained_approval_policy, + sandbox_policy: mut constrained_sandbox_policy, + } = requirements; + + constrained_approval_policy + .set(approval_policy) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + constrained_sandbox_policy + .set(sandbox_policy) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; + + let config = Self { + model, + review_model, + model_context_window: cfg.model_context_window, + model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, + model_provider_id, + model_provider, + cwd: resolved_cwd, + approval_policy: constrained_approval_policy, + sandbox_policy: constrained_sandbox_policy, + did_user_set_custom_approval_policy_or_sandbox_mode, + forced_auto_mode_downgraded_on_windows, + shell_environment_policy, + notify: cfg.notify, + user_instructions, + base_instructions, + developer_instructions, + compact_prompt, + // The config.toml omits "_mode" because it's a config file. However, "_mode" + // is important in code to differentiate the mode from the store implementation. + cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), + mcp_servers: cfg.mcp_servers, + // The config.toml omits "_mode" because it's a config file. However, "_mode" + // is important in code to differentiate the mode from the store implementation. + mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), + model_providers, + project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(32 * 1024), // 32 KiB + project_doc_fallback_filenames: cfg + .project_doc_fallback_filenames + .unwrap_or_default() + .into_iter() + .filter_map(|name| { + let trimmed = name.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .collect(), + tool_output_token_limit: cfg.tool_output_token_limit, + codex_home, + config_layer_stack, + history, + file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), + codex_linux_sandbox_exe, + + hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), + show_raw_agent_reasoning: cfg + .show_raw_agent_reasoning + .or(show_raw_agent_reasoning) + .unwrap_or(false), + model_reasoning_effort: config_profile + .model_reasoning_effort + .or(cfg.model_reasoning_effort), + model_reasoning_summary: config_profile + .model_reasoning_summary + .or(cfg.model_reasoning_summary) + .unwrap_or_default(), + model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, + model_reasoning_summary_format: cfg.model_reasoning_summary_format.clone(), + model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), + chatgpt_base_url: config_profile + .chatgpt_base_url + .or(cfg.chatgpt_base_url) + .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + forced_chatgpt_workspace_id, + forced_login_method, + include_apply_patch_tool: include_apply_patch_tool_flag, + tools_web_search_request, + use_experimental_unified_exec_tool, + ghost_snapshot, + features, + active_profile: active_profile_name, + active_project, + windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), + notices: cfg.notice.unwrap_or_default(), + check_for_update_on_startup, + disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + tui_notifications: cfg + .tui + .as_ref() + .map(|t| t.notifications.clone()) + .unwrap_or_default(), + animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), + show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), + tui_scroll_events_per_tick: cfg.tui.as_ref().and_then(|t| t.scroll_events_per_tick), + tui_scroll_wheel_lines: cfg.tui.as_ref().and_then(|t| t.scroll_wheel_lines), + tui_scroll_trackpad_lines: cfg.tui.as_ref().and_then(|t| t.scroll_trackpad_lines), + tui_scroll_trackpad_accel_events: cfg + .tui + .as_ref() + .and_then(|t| t.scroll_trackpad_accel_events), + tui_scroll_trackpad_accel_max: cfg + .tui + .as_ref() + .and_then(|t| t.scroll_trackpad_accel_max), + tui_scroll_mode: cfg.tui.as_ref().map(|t| t.scroll_mode).unwrap_or_default(), + tui_scroll_wheel_tick_detect_max_ms: cfg + .tui + .as_ref() + .and_then(|t| t.scroll_wheel_tick_detect_max_ms), + tui_scroll_wheel_like_max_duration_ms: cfg + .tui + .as_ref() + .and_then(|t| t.scroll_wheel_like_max_duration_ms), + tui_scroll_invert: cfg.tui.as_ref().map(|t| t.scroll_invert).unwrap_or(false), + otel: { + let t: OtelConfigToml = cfg.otel.unwrap_or_default(); + let log_user_prompt = t.log_user_prompt.unwrap_or(false); + let environment = t + .environment + .unwrap_or(DEFAULT_OTEL_ENVIRONMENT.to_string()); + let exporter = t.exporter.unwrap_or(OtelExporterKind::None); + let trace_exporter = t.trace_exporter.unwrap_or_else(|| exporter.clone()); + OtelConfig { + log_user_prompt, + environment, + exporter, + trace_exporter, + } + }, + }; + Ok(config) + } + + fn load_instructions(codex_dir: Option<&Path>) -> Option { + let base = codex_dir?; + for candidate in [LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME] { + let mut path = base.to_path_buf(); + path.push(candidate); + if let Ok(contents) = std::fs::read_to_string(&path) { + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None + } + + /// If `path` is `Some`, attempts to read the file at the given path and + /// returns its contents as a trimmed `String`. If the file is empty, or + /// is `Some` but cannot be read, returns an `Err`. + fn try_read_non_empty_file( + path: Option<&AbsolutePathBuf>, + context: &str, + ) -> std::io::Result> { + let Some(path) = path else { + return Ok(None); + }; + + let contents = std::fs::read_to_string(path).map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to read {context} {}: {e}", path.display()), + ) + })?; + + let s = contents.trim().to_string(); + if s.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("{context} is empty: {}", path.display()), + )) + } else { + Ok(Some(s)) + } + } + + pub fn set_windows_sandbox_globally(&mut self, value: bool) { + codex_core::safety::set_windows_sandbox_enabled(value); + if value { + self.features.enable(Feature::WindowsSandbox); + } else { + self.features.disable(Feature::WindowsSandbox); + } + self.forced_auto_mode_downgraded_on_windows = !value; + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b3cb3ff24d..4c7a0172ff 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -447,12 +447,24 @@ impl ChatWidget { let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.set_model(&model_for_header); - self.add_to_history(history_cell::new_session_info( + + let session_info = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, - )); + ); + if self.active_cell.as_ref().is_some_and(|cell| { + cell.as_any() + .is::() + }) { + // Replace the startup placeholder header ("model: loading") with the configured + // session header now that we know the selected model. + self.active_cell = Some(Box::new(session_info)); + self.flush_active_cell(); + } else { + self.add_to_history(session_info); + } if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } @@ -929,6 +941,9 @@ impl ChatWidget { .as_any() .downcast_ref::() .is_none() + && !active + .as_any() + .is::() ); if has_non_wait_active { // Do not preempt non-wait active cells with a wait entry. @@ -1447,6 +1462,7 @@ impl ChatWidget { let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + let startup_dir = config.cwd.clone(); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -1462,7 +1478,9 @@ impl ChatWidget { animations_enabled: config.animations, skills: None, }), - active_cell: None, + active_cell: Some(Box::new( + history_cell::StartupSessionHeaderHistoryCell::new(startup_dir), + )), config, model: model.clone(), auth_manager, @@ -1886,6 +1904,15 @@ impl ChatWidget { fn flush_active_cell(&mut self) { self.flush_wait_cell(); if let Some(active) = self.active_cell.take() { + if active + .as_any() + .is::() + { + // Startup header is a transient placeholder and should not be flushed into + // history. It will be replaced once SessionConfigured arrives. + self.active_cell = Some(active); + return; + } self.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_active_cell.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_active_cell.snap new file mode 100644 index 0000000000..edd9adfed6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__startup_header_active_cell.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 2618 +expression: terminal.backend() +--- +" " +"╭───────────────────────────────────────╮ " +"│ >_ OpenAI Codex (v0.0.0) │ " +"│ │ " +"│ model: loading /model to change │ " +"│ directory: ~/code/codex/codex-rs/tui │ " +"╰───────────────────────────────────────╯ " +" " +" " +"› Ask Codex to do anything " +" " +" 100% context left · ? for shortcuts " +" " +" " +" " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 9c81035f1b..e3a7d3cefa 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2600,6 +2600,73 @@ async fn ui_snapshots_small_heights_idle() { } } +#[tokio::test] +async fn startup_header_renders_in_active_cell_before_session_configured_snapshot() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.active_cell = Some(Box::new( + crate::history_cell::StartupSessionHeaderHistoryCell::new(chat.config.cwd.clone()), + )); + + let mut terminal = Terminal::new(TestBackend::new(60, 15)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw startup header"); + + assert_snapshot!("startup_header_active_cell", terminal.backend()); +} + +#[tokio::test] +async fn startup_header_is_replaced_on_session_configured() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.active_cell = Some(Box::new( + crate::history_cell::StartupSessionHeaderHistoryCell::new(chat.config.cwd.clone()), + )); + + chat.handle_codex_event(Event { + id: "session-1".into(), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: ConversationId::new(), + model: "gpt-5.2-codex".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: codex_core::protocol::AskForApproval::OnRequest, + sandbox_policy: codex_core::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + cwd: chat.config.cwd.clone(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: std::env::temp_dir(), + }), + }); + + assert!( + chat.active_cell.is_none(), + "startup header should be replaced and flushed into history" + ); + + let history = drain_insert_history(&mut rx); + let rendered = history + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!( + rendered.contains("model:") && rendered.contains("gpt-5.2-codex"), + "expected configured model in history: {rendered}" + ); + + // Drain rx to avoid unused warnings. + let _ = drain_insert_history(&mut rx); + let _ = op_rx.try_recv(); +} + // Snapshot test: ChatWidget at very small heights (task running) // Validates how status + composer are presented within tight space. #[tokio::test] diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b487c6aeff..80928f170c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -874,6 +874,7 @@ pub(crate) fn new_session_info( // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( model.clone(), + Style::default(), reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, @@ -942,6 +943,7 @@ pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { struct SessionHeaderHistoryCell { version: &'static str, model: String, + model_style: Style, reasoning_effort: Option, directory: PathBuf, } @@ -949,6 +951,7 @@ struct SessionHeaderHistoryCell { impl SessionHeaderHistoryCell { fn new( model: String, + model_style: Style, reasoning_effort: Option, directory: PathBuf, version: &'static str, @@ -956,6 +959,7 @@ impl SessionHeaderHistoryCell { Self { version, model, + model_style, reasoning_effort, directory, } @@ -1028,7 +1032,7 @@ impl HistoryCell for SessionHeaderHistoryCell { let reasoning_label = self.reasoning_label(); let mut model_spans: Vec> = vec![ Span::from(format!("{model_label} ")).dim(), - Span::from(self.model.clone()), + Span::styled(self.model.clone(), self.model_style), ]; if let Some(reasoning) = reasoning_label { model_spans.push(Span::from(" ")); @@ -1056,6 +1060,43 @@ impl HistoryCell for SessionHeaderHistoryCell { } } +#[derive(Debug)] +pub(crate) struct StartupSessionHeaderHistoryCell(SessionHeaderHistoryCell); + +impl StartupSessionHeaderHistoryCell { + pub(crate) fn new(directory: PathBuf) -> Self { + // Render a session header before we know the configured session details. + // This is a transient placeholder until the SessionConfigured event arrives. + let inner = SessionHeaderHistoryCell::new( + "loading".to_string(), + Style::default().dim().italic(), + None, + directory, + CODEX_CLI_VERSION, + ); + Self(inner) + } +} + +impl HistoryCell for StartupSessionHeaderHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + self.0.display_lines(width) + } + + fn desired_height(&self, width: u16) -> u16 { + self.0.desired_height(width) + } + + fn transcript_lines(&self, _width: u16) -> Vec> { + // Do not include this placeholder in transcript/log output. + Vec::new() + } + + fn desired_transcript_height(&self, _width: u16) -> u16 { + 0 + } +} + #[derive(Debug)] pub(crate) struct CompositeHistoryCell { parts: Vec>, @@ -2200,6 +2241,7 @@ mod tests { fn session_header_includes_reasoning_level_when_present() { let cell = SessionHeaderHistoryCell::new( "gpt-4o".to_string(), + Style::default(), Some(ReasoningEffortConfig::High), std::env::temp_dir(), "test", diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ca2652cf88..a39bdc64fd 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -412,12 +412,24 @@ impl ChatWidget { let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.set_model(&model_for_header); - self.add_to_history(history_cell::new_session_info( + let session_info = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, - )); + ); + let is_startup_header_active = self.active_cell.as_ref().is_some_and(|cell| { + cell.as_any() + .downcast_ref::() + .is_some() + }); + if is_startup_header_active { + // Replace the startup placeholder header instead of duplicating it. + self.active_cell = Some(Box::new(session_info)); + self.flush_active_cell(); + } else { + self.add_to_history(session_info); + } if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } @@ -1302,6 +1314,7 @@ impl ChatWidget { model, } = common; let mut config = config; + let startup_dir = config.cwd.clone(); // `model` is an optional override provided by the app. Avoid clobbering the configured // model with an empty string during startup; that would propagate to core and render as a // blank model in the session header (/model current label, etc). @@ -1326,7 +1339,9 @@ impl ChatWidget { animations_enabled: config.animations, skills: None, }), - active_cell: None, + active_cell: Some(Box::new( + history_cell::StartupSessionHeaderHistoryCell::new(startup_dir), + )), config, model: model.clone(), auth_manager, @@ -1714,6 +1729,14 @@ impl ChatWidget { fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { + if active + .as_ref() + .as_any() + .downcast_ref::() + .is_some() + { + return; + } self.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__startup_header_active_cell.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__startup_header_active_cell.snap new file mode 100644 index 0000000000..b5b4f9a665 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__startup_header_active_cell.snap @@ -0,0 +1,11 @@ +--- +source: tui2/src/chatwidget/tests.rs +assertion_line: 160 +expression: active_blob(&chat) +--- +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: loading /model to change │ +│ directory: ~/code/codex/codex-rs/tui2 │ +╰───────────────────────────────────────╯ diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 6fe7cf8599..58f9806608 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -150,6 +150,63 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn startup_header_renders_in_active_cell_before_session_configured_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let startup_dir = chat.config.cwd.clone(); + chat.active_cell = Some(Box::new( + crate::history_cell::StartupSessionHeaderHistoryCell::new(startup_dir), + )); + assert_snapshot!("startup_header_active_cell", active_blob(&chat)); +} + +#[tokio::test] +async fn startup_header_is_replaced_on_session_configured() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + let startup_dir = chat.config.cwd.clone(); + chat.active_cell = Some(Box::new( + crate::history_cell::StartupSessionHeaderHistoryCell::new(startup_dir), + )); + + let conversation_id = ConversationId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + assert!(chat.active_cell.is_none()); + + let cells = drain_insert_history(&mut rx); + let merged_lines = cells + .iter() + .flat_map(|lines| { + lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.clone()) + }) + .collect::(); + assert!( + merged_lines.contains("test-model"), + "expected resolved model to be recorded in history cell, got:\n{merged_lines}", + ); +} + /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 7696a38752..1c3aefd7e7 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -757,6 +757,7 @@ pub(crate) fn new_session_info( // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( model.clone(), + Style::default(), reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, @@ -825,6 +826,7 @@ pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { struct SessionHeaderHistoryCell { version: &'static str, model: String, + model_style: Style, reasoning_effort: Option, directory: PathBuf, } @@ -832,6 +834,7 @@ struct SessionHeaderHistoryCell { impl SessionHeaderHistoryCell { fn new( model: String, + model_style: Style, reasoning_effort: Option, directory: PathBuf, version: &'static str, @@ -839,6 +842,7 @@ impl SessionHeaderHistoryCell { Self { version, model, + model_style, reasoning_effort, directory, } @@ -911,7 +915,7 @@ impl HistoryCell for SessionHeaderHistoryCell { let reasoning_label = self.reasoning_label(); let mut model_spans: Vec> = vec![ Span::from(format!("{model_label} ")).dim(), - Span::from(self.model.clone()), + Span::from(self.model.clone()).set_style(self.model_style), ]; if let Some(reasoning) = reasoning_label { model_spans.push(Span::from(" ")); @@ -939,6 +943,43 @@ impl HistoryCell for SessionHeaderHistoryCell { } } +/// Startup-only session header shown in the active cell before SessionConfigured arrives. +/// +/// This is a transient UI element and is excluded from transcripts/copy. +#[derive(Debug)] +pub(crate) struct StartupSessionHeaderHistoryCell(SessionHeaderHistoryCell); + +impl StartupSessionHeaderHistoryCell { + pub(crate) fn new(directory: PathBuf) -> Self { + let inner = SessionHeaderHistoryCell::new( + "loading".to_string(), + Style::default().dim().italic(), + None, + directory, + CODEX_CLI_VERSION, + ); + Self(inner) + } +} + +impl HistoryCell for StartupSessionHeaderHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + self.0.display_lines(width) + } + + fn desired_height(&self, width: u16) -> u16 { + self.0.desired_height(width) + } + + fn transcript_lines(&self, _width: u16) -> Vec> { + Vec::new() + } + + fn desired_transcript_height(&self, _width: u16) -> u16 { + 0 + } +} + #[derive(Debug)] pub(crate) struct CompositeHistoryCell { parts: Vec>, @@ -2023,6 +2064,7 @@ mod tests { fn session_header_includes_reasoning_level_when_present() { let cell = SessionHeaderHistoryCell::new( "gpt-4o".to_string(), + Style::default(), Some(ReasoningEffortConfig::High), std::env::temp_dir(), "test",