mirror of
https://github.com/openai/codex.git
synced 2026-10-30 08:10:55 +00:00
This PR adds a new `tui.notifications_method` config option that accepts values of "auto", "osc9" and "bel". It defaults to "auto", which attempts to auto-detect whether the terminal supports OSC 9 escape sequences and falls back to BEL if not. The PR also removes the inconsistent handling of notifications on Windows when WSL was used.
3024 lines
121 KiB
Rust
3024 lines
121 KiB
Rust
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event::ExitMode;
|
|
#[cfg(target_os = "windows")]
|
|
use crate::app_event::WindowsSandboxEnableMode;
|
|
#[cfg(target_os = "windows")]
|
|
use crate::app_event::WindowsSandboxFallbackReason;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::bottom_pane::ApprovalRequest;
|
|
use crate::bottom_pane::SelectionItem;
|
|
use crate::bottom_pane::SelectionViewParams;
|
|
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
|
use crate::chatwidget::ChatWidget;
|
|
use crate::chatwidget::ExternalEditorState;
|
|
use crate::cwd_prompt::CwdPromptAction;
|
|
use crate::diff_render::DiffSummary;
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
|
use crate::external_editor;
|
|
use crate::file_search::FileSearchManager;
|
|
use crate::history_cell;
|
|
use crate::history_cell::HistoryCell;
|
|
#[cfg(not(debug_assertions))]
|
|
use crate::history_cell::UpdateAvailableHistoryCell;
|
|
use crate::model_migration::ModelMigrationOutcome;
|
|
use crate::model_migration::migration_copy_for_models;
|
|
use crate::model_migration::run_model_migration_prompt;
|
|
use crate::pager_overlay::Overlay;
|
|
use crate::render::highlight::highlight_bash_to_lines;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::resume_picker::SessionSelection;
|
|
use crate::tui;
|
|
use crate::tui::TuiEvent;
|
|
use crate::update_action::UpdateAction;
|
|
use codex_ansi_escape::ansi_escape_line;
|
|
use codex_app_server_protocol::ConfigLayerSource;
|
|
use codex_core::AuthManager;
|
|
use codex_core::CodexAuth;
|
|
use codex_core::ThreadManager;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::ConfigBuilder;
|
|
use codex_core::config::ConfigOverrides;
|
|
use codex_core::config::edit::ConfigEdit;
|
|
use codex_core::config::edit::ConfigEditsBuilder;
|
|
use codex_core::config_loader::ConfigLayerStackOrdering;
|
|
use codex_core::features::Feature;
|
|
use codex_core::models_manager::manager::RefreshStrategy;
|
|
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
|
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::DeprecationNoticeEvent;
|
|
use codex_core::protocol::Event;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::FinalOutput;
|
|
use codex_core::protocol::ListSkillsResponseEvent;
|
|
use codex_core::protocol::Op;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use codex_core::protocol::SessionSource;
|
|
use codex_core::protocol::SkillErrorInfo;
|
|
use codex_core::protocol::TokenUsage;
|
|
#[cfg(target_os = "windows")]
|
|
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
|
use codex_otel::OtelManager;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::Personality;
|
|
#[cfg(target_os = "windows")]
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use codex_protocol::protocol::SessionConfiguredEvent;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::Wrap;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::collections::VecDeque;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use tokio::select;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::broadcast;
|
|
use tokio::sync::mpsc;
|
|
use tokio::sync::mpsc::error::TryRecvError;
|
|
use tokio::sync::mpsc::error::TrySendError;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
use toml::Value as TomlValue;
|
|
|
|
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
|
|
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppExitInfo {
|
|
pub token_usage: TokenUsage,
|
|
pub thread_id: Option<ThreadId>,
|
|
pub update_action: Option<UpdateAction>,
|
|
pub exit_reason: ExitReason,
|
|
}
|
|
|
|
impl AppExitInfo {
|
|
pub fn fatal(message: impl Into<String>) -> Self {
|
|
Self {
|
|
token_usage: TokenUsage::default(),
|
|
thread_id: None,
|
|
update_action: None,
|
|
exit_reason: ExitReason::Fatal(message.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum AppRunControl {
|
|
Continue,
|
|
Exit(ExitReason),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum ExitReason {
|
|
UserRequested,
|
|
Fatal(String),
|
|
}
|
|
|
|
fn session_summary(token_usage: TokenUsage, thread_id: Option<ThreadId>) -> Option<SessionSummary> {
|
|
if token_usage.is_zero() {
|
|
return None;
|
|
}
|
|
|
|
let usage_line = FinalOutput::from(token_usage).to_string();
|
|
let resume_command = thread_id.map(|thread_id| format!("codex resume {thread_id}"));
|
|
Some(SessionSummary {
|
|
usage_line,
|
|
resume_command,
|
|
})
|
|
}
|
|
|
|
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
|
|
response
|
|
.skills
|
|
.iter()
|
|
.find(|entry| entry.cwd.as_path() == cwd)
|
|
.map(|entry| entry.errors.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) {
|
|
if errors.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let error_count = errors.len();
|
|
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
crate::history_cell::new_warning_event(format!(
|
|
"Skipped loading {error_count} skill(s) due to invalid SKILL.md files."
|
|
)),
|
|
)));
|
|
|
|
for error in errors {
|
|
let path = error.path.display();
|
|
let message = error.message.as_str();
|
|
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
crate::history_cell::new_warning_event(format!("{path}: {message}")),
|
|
)));
|
|
}
|
|
}
|
|
|
|
fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option<DeprecationNoticeEvent>) {
|
|
let Some(DeprecationNoticeEvent { summary, details }) = notice else {
|
|
return;
|
|
};
|
|
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
crate::history_cell::new_deprecation_notice(summary, details),
|
|
)));
|
|
}
|
|
|
|
fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
|
|
let mut disabled_folders = Vec::new();
|
|
|
|
for layer in config
|
|
.config_layer_stack
|
|
.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true)
|
|
{
|
|
let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else {
|
|
continue;
|
|
};
|
|
if layer.disabled_reason.is_none() {
|
|
continue;
|
|
}
|
|
disabled_folders.push((
|
|
dot_codex_folder.as_path().display().to_string(),
|
|
layer
|
|
.disabled_reason
|
|
.as_ref()
|
|
.map(ToString::to_string)
|
|
.unwrap_or_else(|| "config.toml is disabled.".to_string()),
|
|
));
|
|
}
|
|
|
|
if disabled_folders.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let mut message = concat!(
|
|
"Project config.toml files are disabled in the following folders. ",
|
|
"Settings in those files are ignored, but skills and exec policies still load.\n",
|
|
)
|
|
.to_string();
|
|
for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
|
|
let display_index = index + 1;
|
|
message.push_str(&format!(" {display_index}. {folder}\n"));
|
|
message.push_str(&format!(" {reason}\n"));
|
|
}
|
|
|
|
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
history_cell::new_warning_event(message),
|
|
)));
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SessionSummary {
|
|
usage_line: String,
|
|
resume_command: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ThreadEventSnapshot {
|
|
session_configured: Option<Event>,
|
|
events: Vec<Event>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ThreadEventStore {
|
|
session_configured: Option<Event>,
|
|
buffer: VecDeque<Event>,
|
|
user_message_ids: HashSet<String>,
|
|
capacity: usize,
|
|
active: bool,
|
|
}
|
|
|
|
impl ThreadEventStore {
|
|
fn new(capacity: usize) -> Self {
|
|
Self {
|
|
session_configured: None,
|
|
buffer: VecDeque::new(),
|
|
user_message_ids: HashSet::new(),
|
|
capacity,
|
|
active: false,
|
|
}
|
|
}
|
|
|
|
fn new_with_session_configured(capacity: usize, event: Event) -> Self {
|
|
let mut store = Self::new(capacity);
|
|
store.session_configured = Some(event);
|
|
store
|
|
}
|
|
|
|
fn push_event(&mut self, event: Event) {
|
|
match &event.msg {
|
|
EventMsg::SessionConfigured(_) => {
|
|
self.session_configured = Some(event);
|
|
return;
|
|
}
|
|
EventMsg::ItemCompleted(completed) => {
|
|
if let TurnItem::UserMessage(item) = &completed.item {
|
|
if !event.id.is_empty() && self.user_message_ids.contains(&event.id) {
|
|
return;
|
|
}
|
|
let legacy = Event {
|
|
id: event.id,
|
|
msg: item.as_legacy_event(),
|
|
};
|
|
self.push_legacy_event(legacy);
|
|
return;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
self.push_legacy_event(event);
|
|
}
|
|
|
|
fn push_legacy_event(&mut self, event: Event) {
|
|
if let EventMsg::UserMessage(_) = &event.msg
|
|
&& !event.id.is_empty()
|
|
&& !self.user_message_ids.insert(event.id.clone())
|
|
{
|
|
return;
|
|
}
|
|
self.buffer.push_back(event);
|
|
if self.buffer.len() > self.capacity
|
|
&& let Some(removed) = self.buffer.pop_front()
|
|
&& matches!(removed.msg, EventMsg::UserMessage(_))
|
|
&& !removed.id.is_empty()
|
|
{
|
|
self.user_message_ids.remove(&removed.id);
|
|
}
|
|
}
|
|
|
|
fn snapshot(&self) -> ThreadEventSnapshot {
|
|
ThreadEventSnapshot {
|
|
session_configured: self.session_configured.clone(),
|
|
events: self.buffer.iter().cloned().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ThreadEventChannel {
|
|
sender: mpsc::Sender<Event>,
|
|
receiver: Option<mpsc::Receiver<Event>>,
|
|
store: Arc<Mutex<ThreadEventStore>>,
|
|
}
|
|
|
|
impl ThreadEventChannel {
|
|
fn new(capacity: usize) -> Self {
|
|
let (sender, receiver) = mpsc::channel(capacity);
|
|
Self {
|
|
sender,
|
|
receiver: Some(receiver),
|
|
store: Arc::new(Mutex::new(ThreadEventStore::new(capacity))),
|
|
}
|
|
}
|
|
|
|
fn new_with_session_configured(capacity: usize, event: Event) -> Self {
|
|
let (sender, receiver) = mpsc::channel(capacity);
|
|
Self {
|
|
sender,
|
|
receiver: Some(receiver),
|
|
store: Arc::new(Mutex::new(ThreadEventStore::new_with_session_configured(
|
|
capacity, event,
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn should_show_model_migration_prompt(
|
|
current_model: &str,
|
|
target_model: &str,
|
|
seen_migrations: &BTreeMap<String, String>,
|
|
available_models: &[ModelPreset],
|
|
) -> bool {
|
|
if target_model == current_model {
|
|
return false;
|
|
}
|
|
|
|
if let Some(seen_target) = seen_migrations.get(current_model)
|
|
&& seen_target == target_model
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if available_models
|
|
.iter()
|
|
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if available_models
|
|
.iter()
|
|
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
|
|
match migration_config_key {
|
|
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
|
|
.notices
|
|
.hide_gpt_5_1_codex_max_migration_prompt
|
|
.unwrap_or(false),
|
|
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
|
|
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn target_preset_for_upgrade<'a>(
|
|
available_models: &'a [ModelPreset],
|
|
target_model: &str,
|
|
) -> Option<&'a ModelPreset> {
|
|
available_models
|
|
.iter()
|
|
.find(|preset| preset.model == target_model)
|
|
}
|
|
|
|
async fn handle_model_migration_prompt_if_needed(
|
|
tui: &mut tui::Tui,
|
|
config: &mut Config,
|
|
model: &str,
|
|
app_event_tx: &AppEventSender,
|
|
available_models: Vec<ModelPreset>,
|
|
) -> Option<AppExitInfo> {
|
|
let upgrade = available_models
|
|
.iter()
|
|
.find(|preset| preset.model == model)
|
|
.and_then(|preset| preset.upgrade.as_ref());
|
|
|
|
if let Some(ModelUpgrade {
|
|
id: target_model,
|
|
reasoning_effort_mapping,
|
|
migration_config_key,
|
|
model_link,
|
|
upgrade_copy,
|
|
migration_markdown,
|
|
}) = upgrade
|
|
{
|
|
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
|
return None;
|
|
}
|
|
|
|
let target_model = target_model.to_string();
|
|
if !should_show_model_migration_prompt(
|
|
model,
|
|
&target_model,
|
|
&config.notices.model_migrations,
|
|
&available_models,
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
let current_preset = available_models.iter().find(|preset| preset.model == model);
|
|
let target_preset = target_preset_for_upgrade(&available_models, &target_model);
|
|
let target_preset = target_preset?;
|
|
let target_display_name = target_preset.display_name.clone();
|
|
let heading_label = if target_display_name == model {
|
|
target_model.clone()
|
|
} else {
|
|
target_display_name.clone()
|
|
};
|
|
let target_description =
|
|
(!target_preset.description.is_empty()).then(|| target_preset.description.clone());
|
|
let can_opt_out = current_preset.is_some();
|
|
let prompt_copy = migration_copy_for_models(
|
|
model,
|
|
&target_model,
|
|
model_link.clone(),
|
|
upgrade_copy.clone(),
|
|
migration_markdown.clone(),
|
|
heading_label,
|
|
target_description,
|
|
can_opt_out,
|
|
);
|
|
match run_model_migration_prompt(tui, prompt_copy).await {
|
|
ModelMigrationOutcome::Accepted => {
|
|
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model: model.to_string(),
|
|
to_model: target_model.clone(),
|
|
});
|
|
|
|
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
|
|
&& let Some(reasoning_effort) = config.model_reasoning_effort
|
|
{
|
|
reasoning_effort_mapping
|
|
.get(&reasoning_effort)
|
|
.cloned()
|
|
.or(config.model_reasoning_effort)
|
|
} else {
|
|
config.model_reasoning_effort
|
|
};
|
|
|
|
config.model = Some(target_model.clone());
|
|
config.model_reasoning_effort = mapped_effort;
|
|
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
|
|
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
|
|
app_event_tx.send(AppEvent::PersistModelSelection {
|
|
model: target_model.clone(),
|
|
effort: mapped_effort,
|
|
});
|
|
}
|
|
ModelMigrationOutcome::Rejected => {
|
|
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model: model.to_string(),
|
|
to_model: target_model.clone(),
|
|
});
|
|
}
|
|
ModelMigrationOutcome::Exit => {
|
|
return Some(AppExitInfo {
|
|
token_usage: TokenUsage::default(),
|
|
thread_id: None,
|
|
update_action: None,
|
|
exit_reason: ExitReason::UserRequested,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) struct App {
|
|
pub(crate) server: Arc<ThreadManager>,
|
|
pub(crate) otel_manager: OtelManager,
|
|
pub(crate) app_event_tx: AppEventSender,
|
|
pub(crate) chat_widget: ChatWidget,
|
|
pub(crate) auth_manager: Arc<AuthManager>,
|
|
/// Config is stored here so we can recreate ChatWidgets as needed.
|
|
pub(crate) config: Config,
|
|
pub(crate) active_profile: Option<String>,
|
|
cli_kv_overrides: Vec<(String, TomlValue)>,
|
|
harness_overrides: ConfigOverrides,
|
|
runtime_approval_policy_override: Option<AskForApproval>,
|
|
runtime_sandbox_policy_override: Option<SandboxPolicy>,
|
|
|
|
pub(crate) file_search: FileSearchManager,
|
|
|
|
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
|
|
|
// Pager overlay state (Transcript or Static like Diff)
|
|
pub(crate) overlay: Option<Overlay>,
|
|
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
|
has_emitted_history_lines: bool,
|
|
|
|
pub(crate) enhanced_keys_supported: bool,
|
|
|
|
/// Controls the animation thread that sends CommitTick events.
|
|
pub(crate) commit_anim_running: Arc<AtomicBool>,
|
|
|
|
// Esc-backtracking state grouped
|
|
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
|
/// When set, the next draw re-renders the transcript into terminal scrollback once.
|
|
///
|
|
/// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed
|
|
/// transcript cells.
|
|
pub(crate) backtrack_render_pending: bool,
|
|
pub(crate) feedback: codex_feedback::CodexFeedback,
|
|
/// Set when the user confirms an update; propagated on exit.
|
|
pub(crate) pending_update_action: Option<UpdateAction>,
|
|
|
|
/// Ignore the next ShutdownComplete event when we're intentionally
|
|
/// stopping a thread (e.g., before starting a new one).
|
|
suppress_shutdown_complete: bool,
|
|
|
|
windows_sandbox: WindowsSandboxState,
|
|
|
|
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
|
|
active_thread_id: Option<ThreadId>,
|
|
active_thread_rx: Option<mpsc::Receiver<Event>>,
|
|
primary_thread_id: Option<ThreadId>,
|
|
primary_session_configured: Option<SessionConfiguredEvent>,
|
|
pending_primary_events: VecDeque<Event>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct WindowsSandboxState {
|
|
setup_started_at: Option<Instant>,
|
|
// One-shot suppression of the next world-writable scan after user confirmation.
|
|
skip_world_writable_scan_once: bool,
|
|
}
|
|
|
|
fn normalize_harness_overrides_for_cwd(
|
|
mut overrides: ConfigOverrides,
|
|
base_cwd: &Path,
|
|
) -> Result<ConfigOverrides> {
|
|
if overrides.additional_writable_roots.is_empty() {
|
|
return Ok(overrides);
|
|
}
|
|
|
|
let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
|
|
for root in overrides.additional_writable_roots.drain(..) {
|
|
let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?;
|
|
normalized.push(absolute.into_path_buf());
|
|
}
|
|
overrides.additional_writable_roots = normalized;
|
|
Ok(overrides)
|
|
}
|
|
|
|
impl App {
|
|
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
|
&self,
|
|
tui: &mut tui::Tui,
|
|
cfg: codex_core::config::Config,
|
|
) -> crate::chatwidget::ChatWidgetInit {
|
|
crate::chatwidget::ChatWidgetInit {
|
|
config: cfg,
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: self.app_event_tx.clone(),
|
|
// Fork/resume bootstraps here don't carry any prefilled message content.
|
|
initial_user_message: None,
|
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
|
auth_manager: self.auth_manager.clone(),
|
|
models_manager: self.server.get_models_manager(),
|
|
feedback: self.feedback.clone(),
|
|
is_first_run: false,
|
|
model: Some(self.chat_widget.current_model().to_string()),
|
|
otel_manager: self.otel_manager.clone(),
|
|
}
|
|
}
|
|
|
|
async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
|
|
let mut overrides = self.harness_overrides.clone();
|
|
overrides.cwd = Some(cwd.clone());
|
|
let cwd_display = cwd.display().to_string();
|
|
ConfigBuilder::default()
|
|
.codex_home(self.config.codex_home.clone())
|
|
.cli_overrides(self.cli_kv_overrides.clone())
|
|
.harness_overrides(overrides)
|
|
.build()
|
|
.await
|
|
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
|
}
|
|
|
|
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
|
|
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
|
|
&& let Err(err) = config.approval_policy.set(*policy)
|
|
{
|
|
tracing::warn!(%err, "failed to carry forward approval policy override");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to carry forward approval policy override: {err}"
|
|
));
|
|
}
|
|
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
|
|
&& let Err(err) = config.sandbox_policy.set(policy.clone())
|
|
{
|
|
tracing::warn!(%err, "failed to carry forward sandbox policy override");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to carry forward sandbox policy override: {err}"
|
|
));
|
|
}
|
|
}
|
|
|
|
async fn shutdown_current_thread(&mut self) {
|
|
if let Some(thread_id) = self.chat_widget.thread_id() {
|
|
// Clear any in-flight rollback guard when switching threads.
|
|
self.backtrack.pending_rollback = None;
|
|
self.suppress_shutdown_complete = true;
|
|
self.chat_widget.submit_op(Op::Shutdown);
|
|
self.server.remove_thread(&thread_id).await;
|
|
}
|
|
}
|
|
|
|
fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel {
|
|
self.thread_event_channels
|
|
.entry(thread_id)
|
|
.or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY))
|
|
}
|
|
|
|
async fn set_thread_active(&mut self, thread_id: ThreadId, active: bool) {
|
|
if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) {
|
|
let mut store = channel.store.lock().await;
|
|
store.active = active;
|
|
}
|
|
}
|
|
|
|
async fn activate_thread_channel(&mut self, thread_id: ThreadId) {
|
|
if self.active_thread_id.is_some() {
|
|
return;
|
|
}
|
|
self.set_thread_active(thread_id, true).await;
|
|
let receiver = if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) {
|
|
channel.receiver.take()
|
|
} else {
|
|
None
|
|
};
|
|
self.active_thread_id = Some(thread_id);
|
|
self.active_thread_rx = receiver;
|
|
}
|
|
|
|
async fn store_active_thread_receiver(&mut self) {
|
|
let Some(active_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
let Some(receiver) = self.active_thread_rx.take() else {
|
|
return;
|
|
};
|
|
if let Some(channel) = self.thread_event_channels.get_mut(&active_id) {
|
|
let mut store = channel.store.lock().await;
|
|
store.active = false;
|
|
channel.receiver = Some(receiver);
|
|
}
|
|
}
|
|
|
|
async fn activate_thread_for_replay(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Option<(mpsc::Receiver<Event>, ThreadEventSnapshot)> {
|
|
let channel = self.thread_event_channels.get_mut(&thread_id)?;
|
|
let receiver = channel.receiver.take()?;
|
|
let mut store = channel.store.lock().await;
|
|
store.active = true;
|
|
let snapshot = store.snapshot();
|
|
Some((receiver, snapshot))
|
|
}
|
|
|
|
async fn clear_active_thread(&mut self) {
|
|
if let Some(active_id) = self.active_thread_id.take() {
|
|
self.set_thread_active(active_id, false).await;
|
|
}
|
|
self.active_thread_rx = None;
|
|
}
|
|
|
|
async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> {
|
|
let (sender, store) = {
|
|
let channel = self.ensure_thread_channel(thread_id);
|
|
(channel.sender.clone(), Arc::clone(&channel.store))
|
|
};
|
|
|
|
let should_send = {
|
|
let mut guard = store.lock().await;
|
|
guard.push_event(event.clone());
|
|
guard.active
|
|
};
|
|
|
|
if should_send {
|
|
// Never await a bounded channel send on the main TUI loop: if the receiver falls behind,
|
|
// `send().await` can block and the UI stops drawing. If the channel is full, wait in a
|
|
// spawned task instead.
|
|
match sender.try_send(event) {
|
|
Ok(()) => {}
|
|
Err(TrySendError::Full(event)) => {
|
|
tokio::spawn(async move {
|
|
if let Err(err) = sender.send(event).await {
|
|
tracing::warn!("thread {thread_id} event channel closed: {err}");
|
|
}
|
|
});
|
|
}
|
|
Err(TrySendError::Closed(_)) => {
|
|
tracing::warn!("thread {thread_id} event channel closed");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn enqueue_primary_event(&mut self, event: Event) -> Result<()> {
|
|
if let Some(thread_id) = self.primary_thread_id {
|
|
return self.enqueue_thread_event(thread_id, event).await;
|
|
}
|
|
|
|
if let EventMsg::SessionConfigured(session) = &event.msg {
|
|
let thread_id = session.session_id;
|
|
self.primary_thread_id = Some(thread_id);
|
|
self.primary_session_configured = Some(session.clone());
|
|
self.ensure_thread_channel(thread_id);
|
|
self.activate_thread_channel(thread_id).await;
|
|
|
|
let pending = std::mem::take(&mut self.pending_primary_events);
|
|
for pending_event in pending {
|
|
self.enqueue_thread_event(thread_id, pending_event).await?;
|
|
}
|
|
self.enqueue_thread_event(thread_id, event).await?;
|
|
} else {
|
|
self.pending_primary_events.push_back(event);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn open_agent_picker(&mut self) {
|
|
if self.thread_event_channels.is_empty() {
|
|
self.chat_widget
|
|
.add_info_message("No agents available yet.".to_string(), None);
|
|
return;
|
|
}
|
|
|
|
let mut thread_ids: Vec<ThreadId> = self.thread_event_channels.keys().cloned().collect();
|
|
thread_ids.sort_by_key(ToString::to_string);
|
|
|
|
let mut initial_selected_idx = None;
|
|
let items: Vec<SelectionItem> = thread_ids
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, thread_id)| {
|
|
if self.active_thread_id == Some(*thread_id) {
|
|
initial_selected_idx = Some(idx);
|
|
}
|
|
let id = *thread_id;
|
|
SelectionItem {
|
|
name: thread_id.to_string(),
|
|
is_current: self.active_thread_id == Some(*thread_id),
|
|
actions: vec![Box::new(move |tx| {
|
|
tx.send(AppEvent::SelectAgentThread(id));
|
|
})],
|
|
dismiss_on_select: true,
|
|
search_value: Some(thread_id.to_string()),
|
|
..Default::default()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
self.chat_widget.show_selection_view(SelectionViewParams {
|
|
title: Some("Agents".to_string()),
|
|
subtitle: Some("Select a thread to focus".to_string()),
|
|
footer_hint: Some(standard_popup_hint_line()),
|
|
items,
|
|
initial_selected_idx,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> {
|
|
if self.active_thread_id == Some(thread_id) {
|
|
return Ok(());
|
|
}
|
|
|
|
let thread = match self.server.get_thread(thread_id).await {
|
|
Ok(thread) => thread,
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to attach to agent thread {thread_id}: {err}"
|
|
));
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let previous_thread_id = self.active_thread_id;
|
|
self.store_active_thread_receiver().await;
|
|
self.active_thread_id = None;
|
|
let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else {
|
|
self.chat_widget
|
|
.add_error_message(format!("Agent thread {thread_id} is already active."));
|
|
if let Some(previous_thread_id) = previous_thread_id {
|
|
self.activate_thread_channel(previous_thread_id).await;
|
|
}
|
|
return Ok(());
|
|
};
|
|
|
|
self.active_thread_id = Some(thread_id);
|
|
self.active_thread_rx = Some(receiver);
|
|
|
|
let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
|
|
let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread);
|
|
self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx);
|
|
|
|
self.reset_for_thread_switch(tui)?;
|
|
self.replay_thread_snapshot(snapshot);
|
|
self.drain_active_thread_events(tui).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
|
self.overlay = None;
|
|
self.transcript_cells.clear();
|
|
self.deferred_history_lines.clear();
|
|
self.has_emitted_history_lines = false;
|
|
self.backtrack = BacktrackState::default();
|
|
self.backtrack_render_pending = false;
|
|
tui.terminal.clear_scrollback()?;
|
|
tui.terminal.clear()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn reset_thread_event_state(&mut self) {
|
|
self.thread_event_channels.clear();
|
|
self.active_thread_id = None;
|
|
self.active_thread_rx = None;
|
|
self.primary_thread_id = None;
|
|
self.pending_primary_events.clear();
|
|
}
|
|
|
|
async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
|
let Some(mut rx) = self.active_thread_rx.take() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let mut disconnected = false;
|
|
loop {
|
|
match rx.try_recv() {
|
|
Ok(event) => self.handle_codex_event_now(event),
|
|
Err(TryRecvError::Empty) => break,
|
|
Err(TryRecvError::Disconnected) => {
|
|
disconnected = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !disconnected {
|
|
self.active_thread_rx = Some(rx);
|
|
} else {
|
|
self.clear_active_thread().await;
|
|
}
|
|
|
|
if self.backtrack_render_pending {
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) {
|
|
if let Some(event) = snapshot.session_configured {
|
|
self.handle_codex_event_replay(event);
|
|
}
|
|
for event in snapshot.events {
|
|
self.handle_codex_event_replay(event);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn run(
|
|
tui: &mut tui::Tui,
|
|
auth_manager: Arc<AuthManager>,
|
|
mut config: Config,
|
|
cli_kv_overrides: Vec<(String, TomlValue)>,
|
|
harness_overrides: ConfigOverrides,
|
|
active_profile: Option<String>,
|
|
initial_prompt: Option<String>,
|
|
initial_images: Vec<PathBuf>,
|
|
session_selection: SessionSelection,
|
|
feedback: codex_feedback::CodexFeedback,
|
|
is_first_run: bool,
|
|
ollama_chat_support_notice: Option<DeprecationNoticeEvent>,
|
|
) -> Result<AppExitInfo> {
|
|
use tokio_stream::StreamExt;
|
|
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
|
let app_event_tx = AppEventSender::new(app_event_tx);
|
|
emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice);
|
|
emit_project_config_warnings(&app_event_tx, &config);
|
|
tui.set_notification_method(config.tui_notification_method);
|
|
|
|
let harness_overrides =
|
|
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
|
|
let thread_manager = Arc::new(ThreadManager::new(
|
|
config.codex_home.clone(),
|
|
auth_manager.clone(),
|
|
SessionSource::Cli,
|
|
));
|
|
let mut model = thread_manager
|
|
.get_models_manager()
|
|
.get_default_model(&config.model, &config, RefreshStrategy::Offline)
|
|
.await;
|
|
let available_models = thread_manager
|
|
.get_models_manager()
|
|
.list_models(&config, RefreshStrategy::Offline)
|
|
.await;
|
|
let exit_info = handle_model_migration_prompt_if_needed(
|
|
tui,
|
|
&mut config,
|
|
model.as_str(),
|
|
&app_event_tx,
|
|
available_models,
|
|
)
|
|
.await;
|
|
if let Some(exit_info) = exit_info {
|
|
return Ok(exit_info);
|
|
}
|
|
if let Some(updated_model) = config.model.clone() {
|
|
model = updated_model;
|
|
}
|
|
|
|
let auth = auth_manager.auth().await;
|
|
let auth_ref = auth.as_ref();
|
|
let otel_manager = OtelManager::new(
|
|
ThreadId::new(),
|
|
model.as_str(),
|
|
model.as_str(),
|
|
auth_ref.and_then(CodexAuth::get_account_id),
|
|
auth_ref.and_then(CodexAuth::get_account_email),
|
|
auth_ref.map(|auth| auth.mode),
|
|
config.otel.log_user_prompt,
|
|
codex_core::terminal::user_agent(),
|
|
SessionSource::Cli,
|
|
);
|
|
|
|
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
|
let mut chat_widget = match session_selection {
|
|
SessionSelection::StartFresh | SessionSelection::Exit => {
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
initial_user_message: crate::chatwidget::create_initial_user_message(
|
|
initial_prompt.clone(),
|
|
initial_images.clone(),
|
|
// CLI prompt args are plain strings, so they don't provide element ranges.
|
|
Vec::new(),
|
|
),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: thread_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
is_first_run,
|
|
model: Some(model.clone()),
|
|
otel_manager: otel_manager.clone(),
|
|
};
|
|
ChatWidget::new(init, thread_manager.clone())
|
|
}
|
|
SessionSelection::Resume(path) => {
|
|
let resumed = thread_manager
|
|
.resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone())
|
|
.await
|
|
.wrap_err_with(|| {
|
|
let path_display = path.display();
|
|
format!("Failed to resume session from {path_display}")
|
|
})?;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
initial_user_message: crate::chatwidget::create_initial_user_message(
|
|
initial_prompt.clone(),
|
|
initial_images.clone(),
|
|
// CLI prompt args are plain strings, so they don't provide element ranges.
|
|
Vec::new(),
|
|
),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: thread_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
is_first_run,
|
|
model: config.model.clone(),
|
|
otel_manager: otel_manager.clone(),
|
|
};
|
|
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
|
|
}
|
|
SessionSelection::Fork(path) => {
|
|
let forked = thread_manager
|
|
.fork_thread(usize::MAX, config.clone(), path.clone())
|
|
.await
|
|
.wrap_err_with(|| {
|
|
let path_display = path.display();
|
|
format!("Failed to fork session from {path_display}")
|
|
})?;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
initial_user_message: crate::chatwidget::create_initial_user_message(
|
|
initial_prompt.clone(),
|
|
initial_images.clone(),
|
|
// CLI prompt args are plain strings, so they don't provide element ranges.
|
|
Vec::new(),
|
|
),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: thread_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
is_first_run,
|
|
model: config.model.clone(),
|
|
otel_manager: otel_manager.clone(),
|
|
};
|
|
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
|
|
}
|
|
};
|
|
|
|
chat_widget.maybe_prompt_windows_sandbox_enable();
|
|
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
#[cfg(not(debug_assertions))]
|
|
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
|
|
|
let mut app = Self {
|
|
server: thread_manager.clone(),
|
|
otel_manager: otel_manager.clone(),
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager: auth_manager.clone(),
|
|
config,
|
|
active_profile,
|
|
cli_kv_overrides,
|
|
harness_overrides,
|
|
runtime_approval_policy_override: None,
|
|
runtime_sandbox_policy_override: None,
|
|
file_search,
|
|
enhanced_keys_supported,
|
|
transcript_cells: Vec::new(),
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
backtrack_render_pending: false,
|
|
feedback: feedback.clone(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
active_thread_id: None,
|
|
active_thread_rx: None,
|
|
primary_thread_id: None,
|
|
primary_session_configured: None,
|
|
pending_primary_events: VecDeque::new(),
|
|
};
|
|
|
|
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let should_check = WindowsSandboxLevel::from_config(&app.config)
|
|
!= WindowsSandboxLevel::Disabled
|
|
&& matches!(
|
|
app.config.sandbox_policy.get(),
|
|
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
|
| codex_core::protocol::SandboxPolicy::ReadOnly
|
|
)
|
|
&& !app
|
|
.config
|
|
.notices
|
|
.hide_world_writable_warning
|
|
.unwrap_or(false);
|
|
if should_check {
|
|
let cwd = app.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
|
let tx = app.app_event_tx.clone();
|
|
let logs_base_dir = app.config.codex_home.clone();
|
|
let sandbox_policy = app.config.sandbox_policy.get().clone();
|
|
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(debug_assertions))]
|
|
if let Some(latest_version) = upgrade_version {
|
|
let control = app
|
|
.handle_event(
|
|
tui,
|
|
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
|
|
latest_version,
|
|
crate::update_action::get_update_action(),
|
|
))),
|
|
)
|
|
.await?;
|
|
if let AppRunControl::Exit(exit_reason) = control {
|
|
return Ok(AppExitInfo {
|
|
token_usage: app.token_usage(),
|
|
thread_id: app.chat_widget.thread_id(),
|
|
update_action: app.pending_update_action,
|
|
exit_reason,
|
|
});
|
|
}
|
|
}
|
|
|
|
let tui_events = tui.event_stream();
|
|
tokio::pin!(tui_events);
|
|
|
|
tui.frame_requester().schedule_frame();
|
|
|
|
let mut thread_created_rx = thread_manager.subscribe_thread_created();
|
|
let mut listen_for_threads = true;
|
|
|
|
let exit_reason = loop {
|
|
let control = select! {
|
|
Some(event) = app_event_rx.recv() => {
|
|
app.handle_event(tui, event).await?
|
|
}
|
|
active = async {
|
|
if let Some(rx) = app.active_thread_rx.as_mut() {
|
|
rx.recv().await
|
|
} else {
|
|
None
|
|
}
|
|
}, if app.active_thread_rx.is_some() => {
|
|
if let Some(event) = active {
|
|
app.handle_active_thread_event(tui, event)?;
|
|
} else {
|
|
app.clear_active_thread().await;
|
|
}
|
|
AppRunControl::Continue
|
|
}
|
|
Some(event) = tui_events.next() => {
|
|
app.handle_tui_event(tui, event).await?
|
|
}
|
|
// Listen on new thread creation due to collab tools.
|
|
created = thread_created_rx.recv(), if listen_for_threads => {
|
|
match created {
|
|
Ok(thread_id) => {
|
|
app.handle_thread_created(thread_id).await?;
|
|
}
|
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
|
tracing::warn!("thread_created receiver lagged; skipping resync");
|
|
}
|
|
Err(broadcast::error::RecvError::Closed) => {
|
|
listen_for_threads = false;
|
|
}
|
|
}
|
|
AppRunControl::Continue
|
|
}
|
|
};
|
|
match control {
|
|
AppRunControl::Continue => {}
|
|
AppRunControl::Exit(reason) => break reason,
|
|
}
|
|
};
|
|
tui.terminal.clear()?;
|
|
Ok(AppExitInfo {
|
|
token_usage: app.token_usage(),
|
|
thread_id: app.chat_widget.thread_id(),
|
|
update_action: app.pending_update_action,
|
|
exit_reason,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn handle_tui_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
event: TuiEvent,
|
|
) -> Result<AppRunControl> {
|
|
if self.overlay.is_some() {
|
|
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
|
|
} else {
|
|
match event {
|
|
TuiEvent::Key(key_event) => {
|
|
self.handle_key_event(tui, key_event).await;
|
|
}
|
|
TuiEvent::Paste(pasted) => {
|
|
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
|
|
// but tui-textarea expects \n. Normalize CR to LF.
|
|
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
|
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
|
let pasted = pasted.replace("\r", "\n");
|
|
self.chat_widget.handle_paste(pasted);
|
|
}
|
|
TuiEvent::Draw => {
|
|
if self.backtrack_render_pending {
|
|
self.backtrack_render_pending = false;
|
|
self.render_transcript_once(tui);
|
|
}
|
|
self.chat_widget.maybe_post_pending_notification(tui);
|
|
if self
|
|
.chat_widget
|
|
.handle_paste_burst_tick(tui.frame_requester())
|
|
{
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
tui.draw(
|
|
self.chat_widget.desired_height(tui.terminal.size()?.width),
|
|
|frame| {
|
|
self.chat_widget.render(frame.area(), frame.buffer);
|
|
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
|
|
frame.set_cursor_position((x, y));
|
|
}
|
|
},
|
|
)?;
|
|
if self.chat_widget.external_editor_state() == ExternalEditorState::Requested {
|
|
self.chat_widget
|
|
.set_external_editor_state(ExternalEditorState::Active);
|
|
self.app_event_tx.send(AppEvent::LaunchExternalEditor);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(AppRunControl::Continue)
|
|
}
|
|
|
|
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
|
|
match event {
|
|
AppEvent::NewSession => {
|
|
let model = self.chat_widget.current_model().to_string();
|
|
let summary =
|
|
session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
|
|
self.shutdown_current_thread().await;
|
|
if let Err(err) = self.server.remove_and_close_all_threads().await {
|
|
tracing::warn!(error = %err, "failed to close all threads");
|
|
}
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: self.config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: self.app_event_tx.clone(),
|
|
// New sessions start without prefilled message content.
|
|
initial_user_message: None,
|
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
|
auth_manager: self.auth_manager.clone(),
|
|
models_manager: self.server.get_models_manager(),
|
|
feedback: self.feedback.clone(),
|
|
is_first_run: false,
|
|
model: Some(model),
|
|
otel_manager: self.otel_manager.clone(),
|
|
};
|
|
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
|
self.reset_thread_event_state();
|
|
if let Some(summary) = summary {
|
|
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
|
if let Some(command) = summary.resume_command {
|
|
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
|
lines.push(spans.into());
|
|
}
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::OpenResumePicker => {
|
|
match crate::resume_picker::run_resume_picker(
|
|
tui,
|
|
&self.config.codex_home,
|
|
&self.config.model_provider_id,
|
|
false,
|
|
)
|
|
.await?
|
|
{
|
|
SessionSelection::Resume(path) => {
|
|
let current_cwd = self.config.cwd.clone();
|
|
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
|
|
tui,
|
|
¤t_cwd,
|
|
&path,
|
|
CwdPromptAction::Resume,
|
|
true,
|
|
)
|
|
.await?
|
|
{
|
|
Some(cwd) => cwd,
|
|
None => current_cwd.clone(),
|
|
};
|
|
let mut resume_config = if crate::cwds_differ(¤t_cwd, &resume_cwd) {
|
|
match self.rebuild_config_for_cwd(resume_cwd).await {
|
|
Ok(cfg) => cfg,
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to rebuild configuration for resume: {err}"
|
|
));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
}
|
|
} else {
|
|
// No rebuild needed: current_cwd comes from self.config.cwd.
|
|
self.config.clone()
|
|
};
|
|
self.apply_runtime_policy_overrides(&mut resume_config);
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.thread_id(),
|
|
);
|
|
match self
|
|
.server
|
|
.resume_thread_from_rollout(
|
|
resume_config.clone(),
|
|
path.clone(),
|
|
self.auth_manager.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(resumed) => {
|
|
self.shutdown_current_thread().await;
|
|
self.config = resume_config;
|
|
tui.set_notification_method(self.config.tui_notification_method);
|
|
self.file_search = FileSearchManager::new(
|
|
self.config.cwd.clone(),
|
|
self.app_event_tx.clone(),
|
|
);
|
|
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
|
tui,
|
|
self.config.clone(),
|
|
);
|
|
self.chat_widget = ChatWidget::new_from_existing(
|
|
init,
|
|
resumed.thread,
|
|
resumed.session_configured,
|
|
);
|
|
self.reset_thread_event_state();
|
|
if let Some(summary) = summary {
|
|
let mut lines: Vec<Line<'static>> =
|
|
vec![summary.usage_line.clone().into()];
|
|
if let Some(command) = summary.resume_command {
|
|
let spans = vec![
|
|
"To continue this session, run ".into(),
|
|
command.cyan(),
|
|
];
|
|
lines.push(spans.into());
|
|
}
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let path_display = path.display();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to resume session from {path_display}: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
SessionSelection::Exit
|
|
| SessionSelection::StartFresh
|
|
| SessionSelection::Fork(_) => {}
|
|
}
|
|
|
|
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::ForkCurrentSession => {
|
|
let summary =
|
|
session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
|
|
if let Some(path) = self.chat_widget.rollout_path() {
|
|
match self
|
|
.server
|
|
.fork_thread(usize::MAX, self.config.clone(), path.clone())
|
|
.await
|
|
{
|
|
Ok(forked) => {
|
|
self.shutdown_current_thread().await;
|
|
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
|
tui,
|
|
self.config.clone(),
|
|
);
|
|
self.chat_widget = ChatWidget::new_from_existing(
|
|
init,
|
|
forked.thread,
|
|
forked.session_configured,
|
|
);
|
|
self.reset_thread_event_state();
|
|
if let Some(summary) = summary {
|
|
let mut lines: Vec<Line<'static>> =
|
|
vec![summary.usage_line.clone().into()];
|
|
if let Some(command) = summary.resume_command {
|
|
let spans = vec![
|
|
"To continue this session, run ".into(),
|
|
command.cyan(),
|
|
];
|
|
lines.push(spans.into());
|
|
}
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let path_display = path.display();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to fork current session from {path_display}: {err}"
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
self.chat_widget
|
|
.add_error_message("Current session is not ready to fork yet.".to_string());
|
|
}
|
|
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::InsertHistoryCell(cell) => {
|
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
|
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
|
t.insert_cell(cell.clone());
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
self.transcript_cells.push(cell.clone());
|
|
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
|
if !display.is_empty() {
|
|
// Only insert a separating blank line for new cells that are not
|
|
// part of an ongoing stream. Streaming continuations should not
|
|
// accrue extra blank lines between chunks.
|
|
if !cell.is_stream_continuation() {
|
|
if self.has_emitted_history_lines {
|
|
display.insert(0, Line::from(""));
|
|
} else {
|
|
self.has_emitted_history_lines = true;
|
|
}
|
|
}
|
|
if self.overlay.is_some() {
|
|
self.deferred_history_lines.extend(display);
|
|
} else {
|
|
tui.insert_history_lines(display);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::StartCommitAnimation => {
|
|
if self
|
|
.commit_anim_running
|
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
|
.is_ok()
|
|
{
|
|
let tx = self.app_event_tx.clone();
|
|
let running = self.commit_anim_running.clone();
|
|
thread::spawn(move || {
|
|
while running.load(Ordering::Relaxed) {
|
|
thread::sleep(Duration::from_millis(50));
|
|
tx.send(AppEvent::CommitTick);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
AppEvent::StopCommitAnimation => {
|
|
self.commit_anim_running.store(false, Ordering::Release);
|
|
}
|
|
AppEvent::CommitTick => {
|
|
self.chat_widget.on_commit_tick();
|
|
}
|
|
AppEvent::CodexEvent(event) => {
|
|
self.enqueue_primary_event(event).await?;
|
|
}
|
|
AppEvent::Exit(mode) => match mode {
|
|
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
|
|
ExitMode::Immediate => {
|
|
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
|
}
|
|
},
|
|
AppEvent::FatalExitRequest(message) => {
|
|
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
|
|
}
|
|
AppEvent::CodexOp(op) => {
|
|
self.chat_widget.submit_op(op);
|
|
}
|
|
AppEvent::DiffResult(text) => {
|
|
// Clear the in-progress state in the bottom pane
|
|
self.chat_widget.on_diff_complete();
|
|
// Enter alternate screen using TUI helper and build pager lines
|
|
let _ = tui.enter_alt_screen();
|
|
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
|
|
vec!["No changes detected.".italic().into()]
|
|
} else {
|
|
text.lines().map(ansi_escape_line).collect()
|
|
};
|
|
self.overlay = Some(Overlay::new_static_with_lines(
|
|
pager_lines,
|
|
"D I F F".to_string(),
|
|
));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::StartFileSearch(query) => {
|
|
self.file_search.on_user_query(query);
|
|
}
|
|
AppEvent::FileSearchResult { query, matches } => {
|
|
self.chat_widget.apply_file_search_result(query, matches);
|
|
}
|
|
AppEvent::RateLimitSnapshotFetched(snapshot) => {
|
|
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
|
|
}
|
|
AppEvent::UpdateReasoningEffort(effort) => {
|
|
self.on_update_reasoning_effort(effort);
|
|
}
|
|
AppEvent::UpdateModel(model) => {
|
|
self.chat_widget.set_model(&model);
|
|
}
|
|
AppEvent::UpdateCollaborationMode(mask) => {
|
|
self.chat_widget.set_collaboration_mask(mask);
|
|
}
|
|
AppEvent::UpdatePersonality(personality) => {
|
|
self.on_update_personality(personality);
|
|
}
|
|
AppEvent::OpenReasoningPopup { model } => {
|
|
self.chat_widget.open_reasoning_popup(model);
|
|
}
|
|
AppEvent::OpenAllModelsPopup { models } => {
|
|
self.chat_widget.open_all_models_popup(models);
|
|
}
|
|
AppEvent::OpenFullAccessConfirmation {
|
|
preset,
|
|
return_to_permissions,
|
|
} => {
|
|
self.chat_widget
|
|
.open_full_access_confirmation(preset, return_to_permissions);
|
|
}
|
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset,
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
} => {
|
|
self.chat_widget.open_world_writable_warning_confirmation(
|
|
preset,
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
);
|
|
}
|
|
AppEvent::OpenFeedbackNote {
|
|
category,
|
|
include_logs,
|
|
} => {
|
|
self.chat_widget.open_feedback_note(category, include_logs);
|
|
}
|
|
AppEvent::OpenFeedbackConsent { category } => {
|
|
self.chat_widget.open_feedback_consent(category);
|
|
}
|
|
AppEvent::LaunchExternalEditor => {
|
|
if self.chat_widget.external_editor_state() == ExternalEditorState::Active {
|
|
self.launch_external_editor(tui).await;
|
|
}
|
|
}
|
|
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
|
|
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
|
|
}
|
|
AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => {
|
|
self.otel_manager
|
|
.counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]);
|
|
self.chat_widget.clear_windows_sandbox_setup_status();
|
|
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
|
|
self.otel_manager.record_duration(
|
|
"codex.windows_sandbox.elevated_setup_duration_ms",
|
|
started_at.elapsed(),
|
|
&[("result", "failure")],
|
|
);
|
|
}
|
|
self.chat_widget
|
|
.open_windows_sandbox_fallback_prompt(preset, reason);
|
|
}
|
|
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let policy = preset.sandbox.clone();
|
|
let policy_cwd = self.config.cwd.clone();
|
|
let command_cwd = policy_cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let codex_home = self.config.codex_home.clone();
|
|
let tx = self.app_event_tx.clone();
|
|
|
|
// If the elevated setup already ran on this machine, don't prompt for
|
|
// elevation again - just flip the config to use the elevated path.
|
|
if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path())
|
|
{
|
|
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset,
|
|
mode: WindowsSandboxEnableMode::Elevated,
|
|
});
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
|
|
self.chat_widget.show_windows_sandbox_setup_status();
|
|
self.windows_sandbox.setup_started_at = Some(Instant::now());
|
|
let otel_manager = self.otel_manager.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let result = codex_core::windows_sandbox::run_elevated_setup(
|
|
&policy,
|
|
policy_cwd.as_path(),
|
|
command_cwd.as_path(),
|
|
&env_map,
|
|
codex_home.as_path(),
|
|
);
|
|
let event = match result {
|
|
Ok(()) => {
|
|
otel_manager.counter(
|
|
"codex.windows_sandbox.elevated_setup_success",
|
|
1,
|
|
&[],
|
|
);
|
|
AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset: preset.clone(),
|
|
mode: WindowsSandboxEnableMode::Elevated,
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let mut code_tag: Option<String> = None;
|
|
let mut message_tag: Option<String> = None;
|
|
if let Some((code, message)) =
|
|
codex_core::windows_sandbox::elevated_setup_failure_details(
|
|
&err,
|
|
)
|
|
{
|
|
code_tag = Some(code);
|
|
message_tag = Some(message);
|
|
}
|
|
let mut tags: Vec<(&str, &str)> = Vec::new();
|
|
if let Some(code) = code_tag.as_deref() {
|
|
tags.push(("code", code));
|
|
}
|
|
if let Some(message) = message_tag.as_deref() {
|
|
tags.push(("message", message));
|
|
}
|
|
otel_manager.counter(
|
|
"codex.windows_sandbox.elevated_setup_failure",
|
|
1,
|
|
&tags,
|
|
);
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to run elevated Windows sandbox setup"
|
|
);
|
|
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
|
preset,
|
|
reason: WindowsSandboxFallbackReason::ElevationFailed,
|
|
}
|
|
}
|
|
};
|
|
tx.send(event);
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = preset;
|
|
}
|
|
}
|
|
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
self.chat_widget.clear_windows_sandbox_setup_status();
|
|
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
|
|
self.otel_manager.record_duration(
|
|
"codex.windows_sandbox.elevated_setup_duration_ms",
|
|
started_at.elapsed(),
|
|
&[("result", "success")],
|
|
);
|
|
}
|
|
let profile = self.active_profile.as_deref();
|
|
let feature_key = Feature::WindowsSandbox.key();
|
|
let elevated_key = Feature::WindowsSandboxElevated.key();
|
|
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
|
|
let mut builder =
|
|
ConfigEditsBuilder::new(&self.config.codex_home).with_profile(profile);
|
|
if elevated_enabled {
|
|
builder = builder.set_feature_enabled(elevated_key, true);
|
|
} else {
|
|
builder = builder
|
|
.set_feature_enabled(feature_key, true)
|
|
.set_feature_enabled(elevated_key, false);
|
|
}
|
|
match builder.apply().await {
|
|
Ok(()) => {
|
|
if elevated_enabled {
|
|
self.config.set_windows_elevated_sandbox_enabled(true);
|
|
self.chat_widget
|
|
.set_feature_enabled(Feature::WindowsSandboxElevated, true);
|
|
} else {
|
|
self.config.set_windows_sandbox_enabled(true);
|
|
self.config.set_windows_elevated_sandbox_enabled(false);
|
|
self.chat_widget
|
|
.set_feature_enabled(Feature::WindowsSandbox, true);
|
|
self.chat_widget
|
|
.set_feature_enabled(Feature::WindowsSandboxElevated, false);
|
|
}
|
|
self.chat_widget.clear_forced_auto_mode_downgrade();
|
|
let windows_sandbox_level =
|
|
WindowsSandboxLevel::from_config(&self.config);
|
|
if let Some((sample_paths, extra_count, failed_scan)) =
|
|
self.chat_widget.world_writable_warning_details()
|
|
{
|
|
self.app_event_tx.send(AppEvent::CodexOp(
|
|
Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: None,
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: Some(windows_sandbox_level),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
},
|
|
));
|
|
self.app_event_tx.send(
|
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset: Some(preset.clone()),
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
},
|
|
);
|
|
} else {
|
|
self.app_event_tx.send(AppEvent::CodexOp(
|
|
Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: Some(preset.approval),
|
|
sandbox_policy: Some(preset.sandbox.clone()),
|
|
windows_sandbox_level: Some(windows_sandbox_level),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
},
|
|
));
|
|
self.app_event_tx
|
|
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
|
|
self.app_event_tx
|
|
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
|
|
self.chat_widget.add_info_message(
|
|
match mode {
|
|
WindowsSandboxEnableMode::Elevated => {
|
|
"Enabled elevated agent sandbox.".to_string()
|
|
}
|
|
WindowsSandboxEnableMode::Legacy => {
|
|
"Enabled non-elevated agent sandbox.".to_string()
|
|
}
|
|
},
|
|
None,
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to enable Windows sandbox feature"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to enable the Windows sandbox feature: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (preset, mode);
|
|
}
|
|
}
|
|
AppEvent::PersistModelSelection { model, effort } => {
|
|
let profile = self.active_profile.as_deref();
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_model(Some(model.as_str()), effort)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let mut message = format!("Model changed to {model}");
|
|
if let Some(label) = Self::reasoning_label_for(&model, effort) {
|
|
message.push(' ');
|
|
message.push_str(label);
|
|
}
|
|
if let Some(profile) = profile {
|
|
message.push_str(" for ");
|
|
message.push_str(profile);
|
|
message.push_str(" profile");
|
|
}
|
|
self.chat_widget.add_info_message(message, None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist model selection"
|
|
);
|
|
if let Some(profile) = profile {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save model for profile `{profile}`: {err}"
|
|
));
|
|
} else {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save default model: {err}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AppEvent::PersistPersonalitySelection { personality } => {
|
|
let profile = self.active_profile.as_deref();
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_model_personality(Some(personality))
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let label = Self::personality_label(personality);
|
|
let mut message = format!("Personality set to {label}");
|
|
if let Some(profile) = profile {
|
|
message.push_str(" for ");
|
|
message.push_str(profile);
|
|
message.push_str(" profile");
|
|
}
|
|
self.chat_widget.add_info_message(message, None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist personality selection"
|
|
);
|
|
if let Some(profile) = profile {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save personality for profile `{profile}`: {err}"
|
|
));
|
|
} else {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save default personality: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
|
self.runtime_approval_policy_override = Some(policy);
|
|
if let Err(err) = self.config.approval_policy.set(policy) {
|
|
tracing::warn!(%err, "failed to set approval policy on app config");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to set approval policy: {err}"));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.chat_widget.set_approval_policy(policy);
|
|
}
|
|
AppEvent::UpdateSandboxPolicy(policy) => {
|
|
#[cfg(target_os = "windows")]
|
|
let policy_is_workspace_write_or_ro = matches!(
|
|
&policy,
|
|
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
|
| codex_core::protocol::SandboxPolicy::ReadOnly
|
|
);
|
|
|
|
if let Err(err) = self.config.sandbox_policy.set(policy.clone()) {
|
|
tracing::warn!(%err, "failed to set sandbox policy on app config");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to set sandbox policy: {err}"));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly)
|
|
|| WindowsSandboxLevel::from_config(&self.config)
|
|
!= WindowsSandboxLevel::Disabled
|
|
{
|
|
self.config.forced_auto_mode_downgraded_on_windows = false;
|
|
}
|
|
if let Err(err) = self.chat_widget.set_sandbox_policy(policy) {
|
|
tracing::warn!(%err, "failed to set sandbox policy on chat config");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to set sandbox policy: {err}"));
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.runtime_sandbox_policy_override =
|
|
Some(self.config.sandbox_policy.get().clone());
|
|
|
|
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// One-shot suppression if the user just confirmed continue.
|
|
if self.windows_sandbox.skip_world_writable_scan_once {
|
|
self.windows_sandbox.skip_world_writable_scan_once = false;
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
|
|
let should_check = WindowsSandboxLevel::from_config(&self.config)
|
|
!= WindowsSandboxLevel::Disabled
|
|
&& policy_is_workspace_write_or_ro
|
|
&& !self.chat_widget.world_writable_warning_hidden();
|
|
if should_check {
|
|
let cwd = self.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let tx = self.app_event_tx.clone();
|
|
let logs_base_dir = self.config.codex_home.clone();
|
|
let sandbox_policy = self.config.sandbox_policy.get().clone();
|
|
Self::spawn_world_writable_scan(
|
|
cwd,
|
|
env_map,
|
|
logs_base_dir,
|
|
sandbox_policy,
|
|
tx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::UpdateFeatureFlags { updates } => {
|
|
if updates.is_empty() {
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
let windows_sandbox_changed = updates.iter().any(|(feature, _)| {
|
|
matches!(
|
|
feature,
|
|
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
|
|
)
|
|
});
|
|
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(self.active_profile.as_deref());
|
|
for (feature, enabled) in &updates {
|
|
let feature_key = feature.key();
|
|
if *enabled {
|
|
// Update the in-memory configs.
|
|
self.config.features.enable(*feature);
|
|
self.chat_widget.set_feature_enabled(*feature, true);
|
|
builder = builder.set_feature_enabled(feature_key, true);
|
|
} else {
|
|
// Update the in-memory configs.
|
|
self.config.features.disable(*feature);
|
|
self.chat_widget.set_feature_enabled(*feature, false);
|
|
if feature.default_enabled() {
|
|
builder = builder.set_feature_enabled(feature_key, false);
|
|
} else {
|
|
// If the feature already default to `false`, we drop the key
|
|
// in the config file so that the user does not miss the feature
|
|
// once it gets globally released.
|
|
builder = builder.with_edits(vec![ConfigEdit::ClearPath {
|
|
segments: vec!["features".to_string(), feature_key.to_string()],
|
|
}]);
|
|
}
|
|
}
|
|
}
|
|
if windows_sandbox_changed {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
|
self.app_event_tx
|
|
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: None,
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: Some(windows_sandbox_level),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
}));
|
|
}
|
|
}
|
|
if let Err(err) = builder.apply().await {
|
|
tracing::error!(error = %err, "failed to persist feature flags");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update experimental features: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::SkipNextWorldWritableScan => {
|
|
self.windows_sandbox.skip_world_writable_scan_once = true;
|
|
}
|
|
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
|
|
self.chat_widget.set_full_access_warning_acknowledged(ack);
|
|
}
|
|
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
|
|
self.chat_widget
|
|
.set_world_writable_warning_acknowledged(ack);
|
|
}
|
|
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
|
|
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
|
|
}
|
|
AppEvent::PersistFullAccessWarningAcknowledged => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_full_access_warning(true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist full access warning acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save full access confirmation preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistWorldWritableWarningAcknowledged => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_world_writable_warning(true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist world-writable warning acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Agent mode warning preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistRateLimitSwitchPromptHidden => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_rate_limit_model_nudge(true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist rate limit switch prompt preference"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save rate limit reminder preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model,
|
|
to_model,
|
|
} => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist model migration prompt acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save model migration prompt preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::OpenApprovalsPopup => {
|
|
self.chat_widget.open_approvals_popup();
|
|
}
|
|
AppEvent::OpenAgentPicker => {
|
|
self.open_agent_picker();
|
|
}
|
|
AppEvent::SelectAgentThread(thread_id) => {
|
|
self.select_agent_thread(tui, thread_id).await?;
|
|
}
|
|
AppEvent::OpenSkillsList => {
|
|
self.chat_widget.open_skills_list();
|
|
}
|
|
AppEvent::OpenManageSkillsPopup => {
|
|
self.chat_widget.open_manage_skills_popup();
|
|
}
|
|
AppEvent::SetSkillEnabled { path, enabled } => {
|
|
let edits = [ConfigEdit::SetSkillConfig {
|
|
path: path.clone(),
|
|
enabled,
|
|
}];
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits(edits)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.chat_widget.update_skill_enabled(path.clone(), enabled);
|
|
}
|
|
Err(err) => {
|
|
let path_display = path.display();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update skill config for {path_display}: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::OpenPermissionsPopup => {
|
|
self.chat_widget.open_permissions_popup();
|
|
}
|
|
AppEvent::OpenReviewBranchPicker(cwd) => {
|
|
self.chat_widget.show_review_branch_picker(&cwd).await;
|
|
}
|
|
AppEvent::OpenReviewCommitPicker(cwd) => {
|
|
self.chat_widget.show_review_commit_picker(&cwd).await;
|
|
}
|
|
AppEvent::OpenReviewCustomPrompt => {
|
|
self.chat_widget.show_review_custom_prompt();
|
|
}
|
|
AppEvent::SubmitUserMessageWithMode {
|
|
text,
|
|
collaboration_mode,
|
|
} => {
|
|
self.chat_widget
|
|
.submit_user_message_with_mode(text, collaboration_mode);
|
|
}
|
|
AppEvent::ManageSkillsClosed => {
|
|
self.chat_widget.handle_manage_skills_closed();
|
|
}
|
|
AppEvent::FullScreenApprovalRequest(request) => match request {
|
|
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
|
let _ = tui.enter_alt_screen();
|
|
let diff_summary = DiffSummary::new(changes, cwd);
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![diff_summary.into()],
|
|
"P A T C H".to_string(),
|
|
));
|
|
}
|
|
ApprovalRequest::Exec { command, .. } => {
|
|
let _ = tui.enter_alt_screen();
|
|
let full_cmd = strip_bash_lc_and_escape(&command);
|
|
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
|
self.overlay = Some(Overlay::new_static_with_lines(
|
|
full_cmd_lines,
|
|
"E X E C".to_string(),
|
|
));
|
|
}
|
|
ApprovalRequest::McpElicitation {
|
|
server_name,
|
|
message,
|
|
..
|
|
} => {
|
|
let _ = tui.enter_alt_screen();
|
|
let paragraph = Paragraph::new(vec![
|
|
Line::from(vec!["Server: ".into(), server_name.bold()]),
|
|
Line::from(""),
|
|
Line::from(message),
|
|
])
|
|
.wrap(Wrap { trim: false });
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![Box::new(paragraph)],
|
|
"E L I C I T A T I O N".to_string(),
|
|
));
|
|
}
|
|
},
|
|
}
|
|
Ok(AppRunControl::Continue)
|
|
}
|
|
|
|
fn handle_codex_event_now(&mut self, event: Event) {
|
|
if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) {
|
|
self.suppress_shutdown_complete = false;
|
|
return;
|
|
}
|
|
if let EventMsg::ListSkillsResponse(response) = &event.msg {
|
|
let cwd = self.chat_widget.config_ref().cwd.clone();
|
|
let errors = errors_for_cwd(&cwd, response);
|
|
emit_skill_load_warnings(&self.app_event_tx, &errors);
|
|
}
|
|
self.handle_backtrack_event(&event.msg);
|
|
self.chat_widget.handle_codex_event(event);
|
|
}
|
|
|
|
fn handle_codex_event_replay(&mut self, event: Event) {
|
|
self.handle_backtrack_event(&event.msg);
|
|
self.chat_widget.handle_codex_event_replay(event);
|
|
}
|
|
|
|
fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> {
|
|
self.handle_codex_event_now(event);
|
|
if self.backtrack_render_pending {
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
if self.thread_event_channels.contains_key(&thread_id) {
|
|
return Ok(());
|
|
}
|
|
let thread = match self.server.get_thread(thread_id).await {
|
|
Ok(thread) => thread,
|
|
Err(err) => {
|
|
tracing::warn!("failed to attach listener for thread {thread_id}: {err}");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let config_snapshot = thread.config_snapshot().await;
|
|
let event = Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
model: config_snapshot.model,
|
|
model_provider_id: config_snapshot.model_provider_id,
|
|
approval_policy: config_snapshot.approval_policy,
|
|
sandbox_policy: config_snapshot.sandbox_policy,
|
|
cwd: config_snapshot.cwd,
|
|
reasoning_effort: config_snapshot.reasoning_effort,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
rollout_path: thread.rollout_path(),
|
|
}),
|
|
};
|
|
let channel =
|
|
ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event);
|
|
let sender = channel.sender.clone();
|
|
let store = Arc::clone(&channel.store);
|
|
self.thread_event_channels.insert(thread_id, channel);
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let event = match thread.next_event().await {
|
|
Ok(event) => event,
|
|
Err(err) => {
|
|
tracing::debug!("external thread {thread_id} listener stopped: {err}");
|
|
break;
|
|
}
|
|
};
|
|
let should_send = {
|
|
let mut guard = store.lock().await;
|
|
guard.push_event(event.clone());
|
|
guard.active
|
|
};
|
|
if should_send && let Err(err) = sender.send(event).await {
|
|
tracing::debug!("external thread {thread_id} channel closed: {err}");
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {
|
|
match reasoning_effort {
|
|
Some(ReasoningEffortConfig::Minimal) => "minimal",
|
|
Some(ReasoningEffortConfig::Low) => "low",
|
|
Some(ReasoningEffortConfig::Medium) => "medium",
|
|
Some(ReasoningEffortConfig::High) => "high",
|
|
Some(ReasoningEffortConfig::XHigh) => "xhigh",
|
|
None | Some(ReasoningEffortConfig::None) => "default",
|
|
}
|
|
}
|
|
|
|
fn reasoning_label_for(
|
|
model: &str,
|
|
reasoning_effort: Option<ReasoningEffortConfig>,
|
|
) -> Option<&'static str> {
|
|
(!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort))
|
|
}
|
|
|
|
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
|
self.chat_widget.token_usage()
|
|
}
|
|
|
|
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
|
// TODO(aibrahim): Remove this and don't use config as a state object.
|
|
// Instead, explicitly pass the stored collaboration mode's effort into new sessions.
|
|
self.config.model_reasoning_effort = effort;
|
|
self.chat_widget.set_reasoning_effort(effort);
|
|
}
|
|
|
|
fn on_update_personality(&mut self, personality: Personality) {
|
|
self.config.model_personality = Some(personality);
|
|
self.chat_widget.set_personality(personality);
|
|
}
|
|
|
|
fn personality_label(personality: Personality) -> &'static str {
|
|
match personality {
|
|
Personality::Friendly => "Friendly",
|
|
Personality::Pragmatic => "Pragmatic",
|
|
}
|
|
}
|
|
|
|
async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
|
|
let editor_cmd = match external_editor::resolve_editor_command() {
|
|
Ok(cmd) => cmd,
|
|
Err(external_editor::EditorError::MissingEditor) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(
|
|
"Cannot open external editor: set $VISUAL or $EDITOR before starting Codex."
|
|
.to_string(),
|
|
));
|
|
self.reset_external_editor_state(tui);
|
|
return;
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!(
|
|
"Failed to open editor: {err}",
|
|
)));
|
|
self.reset_external_editor_state(tui);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let seed = self.chat_widget.composer_text_with_pending();
|
|
let editor_result = tui
|
|
.with_restored(tui::RestoreMode::KeepRaw, || async {
|
|
external_editor::run_editor(&seed, &editor_cmd).await
|
|
})
|
|
.await;
|
|
self.reset_external_editor_state(tui);
|
|
|
|
match editor_result {
|
|
Ok(new_text) => {
|
|
// Trim trailing whitespace
|
|
let cleaned = new_text.trim_end().to_string();
|
|
self.chat_widget.apply_external_edit(cleaned);
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!(
|
|
"Failed to open editor: {err}",
|
|
)));
|
|
}
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
fn request_external_editor_launch(&mut self, tui: &mut tui::Tui) {
|
|
self.chat_widget
|
|
.set_external_editor_state(ExternalEditorState::Requested);
|
|
self.chat_widget.set_footer_hint_override(Some(vec![(
|
|
EXTERNAL_EDITOR_HINT.to_string(),
|
|
String::new(),
|
|
)]));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
fn reset_external_editor_state(&mut self, tui: &mut tui::Tui) {
|
|
self.chat_widget
|
|
.set_external_editor_state(ExternalEditorState::Closed);
|
|
self.chat_widget.set_footer_hint_override(None);
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
|
match key_event {
|
|
KeyEvent {
|
|
code: KeyCode::Char('t'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} => {
|
|
// Enter alternate screen and set viewport to full size.
|
|
let _ = tui.enter_alt_screen();
|
|
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Char('g'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} => {
|
|
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
|
|
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
|
|
if self.overlay.is_none()
|
|
&& self.chat_widget.can_launch_external_editor()
|
|
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
|
|
{
|
|
self.request_external_editor_launch(tui);
|
|
}
|
|
}
|
|
// Esc primes/advances backtracking only in normal (not working) mode
|
|
// with the composer focused and empty. In any other state, forward
|
|
// Esc so the active UI (e.g. status indicator, modals, popups)
|
|
// handles it.
|
|
KeyEvent {
|
|
code: KeyCode::Esc,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
if self.chat_widget.is_normal_backtrack_mode()
|
|
&& self.chat_widget.composer_is_empty()
|
|
{
|
|
self.handle_backtrack_esc_key(tui);
|
|
} else {
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
}
|
|
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if self.backtrack.primed
|
|
&& self.backtrack.nth_user_message != usize::MAX
|
|
&& self.chat_widget.composer_is_empty() =>
|
|
{
|
|
if let Some(selection) = self.confirm_backtrack_from_main() {
|
|
self.apply_backtrack_selection(tui, selection);
|
|
}
|
|
}
|
|
KeyEvent {
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
// Any non-Esc key press should cancel a primed backtrack.
|
|
// This avoids stale "Esc-primed" state after the user starts typing
|
|
// (even if they later backspace to empty).
|
|
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
|
self.reset_backtrack_state();
|
|
}
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
_ => {
|
|
// Ignore Release key events.
|
|
}
|
|
};
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn spawn_world_writable_scan(
|
|
cwd: PathBuf,
|
|
env_map: std::collections::HashMap<String, String>,
|
|
logs_base_dir: PathBuf,
|
|
sandbox_policy: codex_core::protocol::SandboxPolicy,
|
|
tx: AppEventSender,
|
|
) {
|
|
tokio::task::spawn_blocking(move || {
|
|
let result = codex_windows_sandbox::apply_world_writable_scan_and_denies(
|
|
&logs_base_dir,
|
|
&cwd,
|
|
&env_map,
|
|
&sandbox_policy,
|
|
Some(logs_base_dir.as_path()),
|
|
);
|
|
if result.is_err() {
|
|
// Scan failed: warn without examples.
|
|
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset: None,
|
|
sample_paths: Vec::new(),
|
|
extra_count: 0usize,
|
|
failed_scan: true,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_backtrack::user_count;
|
|
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
|
use crate::file_search::FileSearchManager;
|
|
use crate::history_cell::AgentMessageCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::UserHistoryCell;
|
|
use crate::history_cell::new_session_info;
|
|
use codex_core::AuthManager;
|
|
use codex_core::CodexAuth;
|
|
use codex_core::ThreadManager;
|
|
use codex_core::config::ConfigBuilder;
|
|
use codex_core::config::ConfigOverrides;
|
|
use codex_core::models_manager::manager::ModelsManager;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::Event;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use codex_core::protocol::SessionConfiguredEvent;
|
|
use codex_core::protocol::SessionSource;
|
|
use codex_otel::OtelManager;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::user_input::TextElement;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
use ratatui::prelude::Line;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use tempfile::tempdir;
|
|
use tokio::time;
|
|
|
|
#[test]
|
|
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
|
|
let temp_dir = tempdir()?;
|
|
let base_cwd = temp_dir.path().join("base");
|
|
std::fs::create_dir_all(&base_cwd)?;
|
|
|
|
let overrides = ConfigOverrides {
|
|
additional_writable_roots: vec![PathBuf::from("rel")],
|
|
..Default::default()
|
|
};
|
|
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
|
|
|
|
assert_eq!(
|
|
normalized.additional_writable_roots,
|
|
vec![base_cwd.join("rel")]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(1));
|
|
app.set_thread_active(thread_id, true).await;
|
|
|
|
let event = Event {
|
|
id: String::new(),
|
|
msg: EventMsg::ShutdownComplete,
|
|
};
|
|
|
|
app.enqueue_thread_event(thread_id, event.clone()).await?;
|
|
time::timeout(
|
|
Duration::from_millis(50),
|
|
app.enqueue_thread_event(thread_id, event),
|
|
)
|
|
.await
|
|
.expect("enqueue_thread_event blocked on a full channel")?;
|
|
|
|
let mut rx = app
|
|
.thread_event_channels
|
|
.get_mut(&thread_id)
|
|
.expect("missing thread channel")
|
|
.receiver
|
|
.take()
|
|
.expect("missing receiver");
|
|
|
|
time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for first event")
|
|
.expect("channel closed unexpectedly");
|
|
time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for second event")
|
|
.expect("channel closed unexpectedly");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn make_test_app() -> App {
|
|
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
|
let config = chat_widget.config_ref().clone();
|
|
let server = Arc::new(ThreadManager::with_models_provider(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
config.model_provider.clone(),
|
|
));
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
let model = ModelsManager::get_model_offline(config.model.as_deref());
|
|
let otel_manager = test_otel_manager(&config, model.as_str());
|
|
|
|
App {
|
|
server,
|
|
otel_manager,
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager,
|
|
config,
|
|
active_profile: None,
|
|
cli_kv_overrides: Vec::new(),
|
|
harness_overrides: ConfigOverrides::default(),
|
|
runtime_approval_policy_override: None,
|
|
runtime_sandbox_policy_override: None,
|
|
file_search,
|
|
transcript_cells: Vec::new(),
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
enhanced_keys_supported: false,
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
backtrack_render_pending: false,
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
active_thread_id: None,
|
|
active_thread_rx: None,
|
|
primary_thread_id: None,
|
|
primary_session_configured: None,
|
|
pending_primary_events: VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
async fn make_test_app_with_channels() -> (
|
|
App,
|
|
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
|
tokio::sync::mpsc::UnboundedReceiver<Op>,
|
|
) {
|
|
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
|
|
let config = chat_widget.config_ref().clone();
|
|
let server = Arc::new(ThreadManager::with_models_provider(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
config.model_provider.clone(),
|
|
));
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
let model = ModelsManager::get_model_offline(config.model.as_deref());
|
|
let otel_manager = test_otel_manager(&config, model.as_str());
|
|
|
|
(
|
|
App {
|
|
server,
|
|
otel_manager,
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager,
|
|
config,
|
|
active_profile: None,
|
|
cli_kv_overrides: Vec::new(),
|
|
harness_overrides: ConfigOverrides::default(),
|
|
runtime_approval_policy_override: None,
|
|
runtime_sandbox_policy_override: None,
|
|
file_search,
|
|
transcript_cells: Vec::new(),
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
enhanced_keys_supported: false,
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
backtrack_render_pending: false,
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
active_thread_id: None,
|
|
active_thread_rx: None,
|
|
primary_thread_id: None,
|
|
primary_session_configured: None,
|
|
pending_primary_events: VecDeque::new(),
|
|
},
|
|
rx,
|
|
op_rx,
|
|
)
|
|
}
|
|
|
|
fn test_otel_manager(config: &Config, model: &str) -> OtelManager {
|
|
let model_info = ModelsManager::construct_model_info_offline(model, config);
|
|
OtelManager::new(
|
|
ThreadId::new(),
|
|
model,
|
|
model_info.slug.as_str(),
|
|
None,
|
|
None,
|
|
None,
|
|
false,
|
|
"test".to_string(),
|
|
SessionSource::Cli,
|
|
)
|
|
}
|
|
|
|
fn all_model_presets() -> Vec<ModelPreset> {
|
|
codex_core::models_manager::model_presets::all_model_presets().clone()
|
|
}
|
|
|
|
fn model_migration_copy_to_plain_text(
|
|
copy: &crate::model_migration::ModelMigrationCopy,
|
|
) -> String {
|
|
if let Some(markdown) = copy.markdown.as_ref() {
|
|
return markdown.clone();
|
|
}
|
|
let mut s = String::new();
|
|
for span in ©.heading {
|
|
s.push_str(&span.content);
|
|
}
|
|
s.push('\n');
|
|
s.push('\n');
|
|
for line in ©.content {
|
|
for span in &line.spans {
|
|
s.push_str(&span.content);
|
|
}
|
|
s.push('\n');
|
|
}
|
|
s
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_migration_prompt_only_shows_for_deprecated_models() {
|
|
let seen = BTreeMap::new();
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5",
|
|
"gpt-5.1",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex",
|
|
"gpt-5.1-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex-mini",
|
|
"gpt-5.1-codex-mini",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex-max",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_migration_prompt_respects_hide_flag_and_self_target() {
|
|
let mut seen = BTreeMap::new();
|
|
seen.insert("gpt-5".to_string(), "gpt-5.1".to_string());
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5",
|
|
"gpt-5.1",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1",
|
|
"gpt-5.1",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_migration_prompt_skips_when_target_missing() {
|
|
let mut available = all_model_presets();
|
|
let mut current = available
|
|
.iter()
|
|
.find(|preset| preset.model == "gpt-5-codex")
|
|
.cloned()
|
|
.expect("preset present");
|
|
current.upgrade = Some(ModelUpgrade {
|
|
id: "missing-target".to_string(),
|
|
reasoning_effort_mapping: None,
|
|
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
|
|
model_link: None,
|
|
upgrade_copy: None,
|
|
migration_markdown: None,
|
|
});
|
|
available.retain(|preset| preset.model != "gpt-5-codex");
|
|
available.push(current.clone());
|
|
|
|
assert!(should_show_model_migration_prompt(
|
|
¤t.model,
|
|
"missing-target",
|
|
&BTreeMap::new(),
|
|
&available,
|
|
));
|
|
|
|
assert!(target_preset_for_upgrade(&available, "missing-target").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_migration_prompt_shows_for_hidden_model() {
|
|
let codex_home = tempdir().expect("temp codex home");
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.build()
|
|
.await
|
|
.expect("config");
|
|
|
|
let available_models = all_model_presets();
|
|
let current = available_models
|
|
.iter()
|
|
.find(|preset| preset.model == "gpt-5.1-codex")
|
|
.cloned()
|
|
.expect("gpt-5.1-codex preset present");
|
|
assert!(
|
|
!current.show_in_picker,
|
|
"expected gpt-5.1-codex to be hidden from picker for this test"
|
|
);
|
|
|
|
let upgrade = current.upgrade.as_ref().expect("upgrade configured");
|
|
assert!(
|
|
should_show_model_migration_prompt(
|
|
¤t.model,
|
|
&upgrade.id,
|
|
&config.notices.model_migrations,
|
|
&available_models,
|
|
),
|
|
"expected migration prompt to be eligible for hidden model"
|
|
);
|
|
|
|
let target = target_preset_for_upgrade(&available_models, &upgrade.id)
|
|
.expect("upgrade target present");
|
|
let target_description =
|
|
(!target.description.is_empty()).then(|| target.description.clone());
|
|
let can_opt_out = true;
|
|
let copy = migration_copy_for_models(
|
|
¤t.model,
|
|
&upgrade.id,
|
|
upgrade.model_link.clone(),
|
|
upgrade.upgrade_copy.clone(),
|
|
upgrade.migration_markdown.clone(),
|
|
target.display_name.clone(),
|
|
target_description,
|
|
can_opt_out,
|
|
);
|
|
|
|
// Snapshot the copy we would show; rendering is covered by model_migration snapshots.
|
|
assert_snapshot!(
|
|
"model_migration_prompt_shows_for_hidden_model",
|
|
model_migration_copy_to_plain_text(©)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_reasoning_effort_updates_collaboration_mode() {
|
|
let mut app = make_test_app().await;
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
|
|
|
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
|
|
assert_eq!(
|
|
app.chat_widget.current_reasoning_effort(),
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
assert_eq!(
|
|
app.config.model_reasoning_effort,
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
let user_cell = |text: &str,
|
|
text_elements: Vec<TextElement>,
|
|
local_image_paths: Vec<PathBuf>|
|
|
-> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
text_elements,
|
|
local_image_paths,
|
|
}) as Arc<dyn HistoryCell>
|
|
};
|
|
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from(text.to_string())],
|
|
true,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
let make_header = |is_first| {
|
|
let event = SessionConfiguredEvent {
|
|
session_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
model: "gpt-test".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: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.chat_widget.current_model(),
|
|
event,
|
|
is_first,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
let placeholder = "[Image #1]";
|
|
let edited_text = format!("follow-up (edited) {placeholder}");
|
|
let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len();
|
|
let edited_text_elements = vec![TextElement::new(edited_range.into(), None)];
|
|
let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")];
|
|
|
|
// Simulate a transcript with duplicated history (e.g., from prior backtracks)
|
|
// and an edited turn appended after a session header boundary.
|
|
app.transcript_cells = vec![
|
|
make_header(true),
|
|
user_cell("first question", Vec::new(), Vec::new()),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up", Vec::new(), Vec::new()),
|
|
agent_cell("answer follow-up"),
|
|
make_header(false),
|
|
user_cell("first question", Vec::new(), Vec::new()),
|
|
agent_cell("answer first"),
|
|
user_cell(
|
|
&edited_text,
|
|
edited_text_elements.clone(),
|
|
edited_local_image_paths.clone(),
|
|
),
|
|
agent_cell("answer edited"),
|
|
];
|
|
|
|
assert_eq!(user_count(&app.transcript_cells), 2);
|
|
|
|
let base_id = ThreadId::new();
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: base_id,
|
|
forked_from_id: None,
|
|
model: "gpt-test".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: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
});
|
|
|
|
app.backtrack.base_id = Some(base_id);
|
|
app.backtrack.primed = true;
|
|
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
|
|
|
let selection = app
|
|
.confirm_backtrack_from_main()
|
|
.expect("backtrack selection");
|
|
assert_eq!(selection.nth_user_message, 1);
|
|
assert_eq!(selection.prefill, edited_text);
|
|
assert_eq!(selection.text_elements, edited_text_elements);
|
|
assert_eq!(selection.local_image_paths, edited_local_image_paths);
|
|
|
|
app.apply_backtrack_rollback(selection);
|
|
|
|
let mut rollback_turns = None;
|
|
while let Ok(op) = op_rx.try_recv() {
|
|
if let Op::ThreadRollback { num_turns } = op {
|
|
rollback_turns = Some(num_turns);
|
|
}
|
|
}
|
|
|
|
assert_eq!(rollback_turns, Some(1));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_session_requests_shutdown_for_previous_conversation() {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
let thread_id = ThreadId::new();
|
|
let event = SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
model: "gpt-test".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: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(event),
|
|
});
|
|
|
|
while app_event_rx.try_recv().is_ok() {}
|
|
while op_rx.try_recv().is_ok() {}
|
|
|
|
app.shutdown_current_thread().await;
|
|
|
|
match op_rx.try_recv() {
|
|
Ok(Op::Shutdown) => {}
|
|
Ok(other) => panic!("expected Op::Shutdown, got {other:?}"),
|
|
Err(_) => panic!("expected shutdown op to be sent"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_summary_skip_zero_usage() {
|
|
assert!(session_summary(TokenUsage::default(), None).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_summary_includes_resume_hint() {
|
|
let usage = TokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 2,
|
|
total_tokens: 12,
|
|
..Default::default()
|
|
};
|
|
let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
|
|
|
let summary = session_summary(usage, Some(conversation)).expect("summary");
|
|
assert_eq!(
|
|
summary.usage_line,
|
|
"Token usage: total=12 input=10 output=2"
|
|
);
|
|
assert_eq!(
|
|
summary.resume_command,
|
|
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
|
|
);
|
|
}
|
|
}
|