mirror of
https://github.com/openai/codex.git
synced 2026-05-04 11:26:33 +00:00
Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
1511 lines
60 KiB
Rust
1511 lines
60 KiB
Rust
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::bottom_pane::ApprovalRequest;
|
|
use crate::chatwidget::ChatWidget;
|
|
use crate::diff_render::DiffSummary;
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
|
use crate::file_search::FileSearchManager;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::model_migration::ModelMigrationOutcome;
|
|
use crate::model_migration::migration_copy_for_config;
|
|
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::ResumeSelection;
|
|
use crate::skill_error_prompt::SkillErrorPromptOutcome;
|
|
use crate::skill_error_prompt::run_skill_error_prompt;
|
|
use crate::tui;
|
|
use crate::tui::TuiEvent;
|
|
use crate::update_action::UpdateAction;
|
|
use codex_ansi_escape::ansi_escape_line;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_core::AuthManager;
|
|
use codex_core::ConversationManager;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::edit::ConfigEditsBuilder;
|
|
use codex_core::features::Feature;
|
|
use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
|
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
|
use codex_core::openai_models::models_manager::ModelsManager;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::FinalOutput;
|
|
use codex_core::protocol::Op;
|
|
use codex_core::protocol::SessionSource;
|
|
use codex_core::protocol::TokenUsage;
|
|
use codex_core::skills::load_skills;
|
|
use codex_core::skills::model::SkillMetadata;
|
|
use codex_protocol::ConversationId;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
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::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
use tokio::select;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
|
|
#[cfg(not(debug_assertions))]
|
|
use crate::history_cell::UpdateAvailableHistoryCell;
|
|
|
|
const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey];
|
|
const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey];
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppExitInfo {
|
|
pub token_usage: TokenUsage,
|
|
pub conversation_id: Option<ConversationId>,
|
|
pub update_action: Option<UpdateAction>,
|
|
}
|
|
|
|
impl From<AppExitInfo> for codex_tui::AppExitInfo {
|
|
fn from(info: AppExitInfo) -> Self {
|
|
codex_tui::AppExitInfo {
|
|
token_usage: info.token_usage,
|
|
conversation_id: info.conversation_id,
|
|
update_action: info.update_action.map(Into::into),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn session_summary(
|
|
token_usage: TokenUsage,
|
|
conversation_id: Option<ConversationId>,
|
|
) -> Option<SessionSummary> {
|
|
if token_usage.is_zero() {
|
|
return None;
|
|
}
|
|
|
|
let usage_line = FinalOutput::from(token_usage).to_string();
|
|
let resume_command =
|
|
conversation_id.map(|conversation_id| format!("codex resume {conversation_id}"));
|
|
Some(SessionSummary {
|
|
usage_line,
|
|
resume_command,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SessionSummary {
|
|
usage_line: String,
|
|
resume_command: Option<String>,
|
|
}
|
|
|
|
fn should_show_model_migration_prompt(
|
|
current_model: &str,
|
|
target_model: &str,
|
|
hide_prompt_flag: Option<bool>,
|
|
available_models: Vec<ModelPreset>,
|
|
) -> bool {
|
|
if target_model == current_model || hide_prompt_flag.unwrap_or(false) {
|
|
return false;
|
|
}
|
|
|
|
available_models
|
|
.iter()
|
|
.filter(|preset| preset.upgrade.is_some())
|
|
.any(|preset| preset.model == current_model)
|
|
}
|
|
|
|
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Option<bool> {
|
|
match migration_config_key {
|
|
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => {
|
|
config.notices.hide_gpt_5_1_codex_max_migration_prompt
|
|
}
|
|
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => config.notices.hide_gpt5_1_migration_prompt,
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn handle_model_migration_prompt_if_needed(
|
|
tui: &mut tui::Tui,
|
|
config: &mut Config,
|
|
model: &str,
|
|
app_event_tx: &AppEventSender,
|
|
auth_mode: Option<AuthMode>,
|
|
models_manager: Arc<ModelsManager>,
|
|
) -> Option<AppExitInfo> {
|
|
let available_models = models_manager.list_models(config).await;
|
|
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,
|
|
}) = upgrade
|
|
{
|
|
if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) {
|
|
return None;
|
|
}
|
|
|
|
let target_model = target_model.to_string();
|
|
let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str());
|
|
if !should_show_model_migration_prompt(
|
|
model,
|
|
&target_model,
|
|
hide_prompt_flag,
|
|
available_models.clone(),
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
let prompt_copy = migration_copy_for_config(migration_config_key.as_str());
|
|
match run_model_migration_prompt(tui, prompt_copy).await {
|
|
ModelMigrationOutcome::Accepted => {
|
|
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
migration_config: migration_config_key.to_string(),
|
|
});
|
|
config.model = Some(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_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 {
|
|
migration_config: migration_config_key.to_string(),
|
|
});
|
|
}
|
|
ModelMigrationOutcome::Exit => {
|
|
return Some(AppExitInfo {
|
|
token_usage: TokenUsage::default(),
|
|
conversation_id: None,
|
|
update_action: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) struct App {
|
|
pub(crate) server: Arc<ConversationManager>,
|
|
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) current_model: String,
|
|
pub(crate) active_profile: Option<String>,
|
|
|
|
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,
|
|
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 conversation (e.g., before starting a new one).
|
|
suppress_shutdown_complete: bool,
|
|
|
|
// One-shot suppression of the next world-writable scan after user confirmation.
|
|
skip_world_writable_scan_once: bool,
|
|
|
|
pub(crate) skills: Option<Vec<SkillMetadata>>,
|
|
}
|
|
|
|
impl App {
|
|
async fn shutdown_current_conversation(&mut self) {
|
|
if let Some(conversation_id) = self.chat_widget.conversation_id() {
|
|
self.suppress_shutdown_complete = true;
|
|
self.chat_widget.submit_op(Op::Shutdown);
|
|
self.server.remove_conversation(&conversation_id).await;
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn run(
|
|
tui: &mut tui::Tui,
|
|
auth_manager: Arc<AuthManager>,
|
|
mut config: Config,
|
|
active_profile: Option<String>,
|
|
initial_prompt: Option<String>,
|
|
initial_images: Vec<PathBuf>,
|
|
resume_selection: ResumeSelection,
|
|
feedback: codex_feedback::CodexFeedback,
|
|
is_first_run: bool,
|
|
) -> 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);
|
|
|
|
let auth_mode = auth_manager.auth().map(|auth| auth.mode);
|
|
let conversation_manager = Arc::new(ConversationManager::new(
|
|
auth_manager.clone(),
|
|
SessionSource::Cli,
|
|
));
|
|
let mut model = conversation_manager
|
|
.get_models_manager()
|
|
.get_model(&config.model, &config)
|
|
.await;
|
|
let exit_info = handle_model_migration_prompt_if_needed(
|
|
tui,
|
|
&mut config,
|
|
model.as_str(),
|
|
&app_event_tx,
|
|
auth_mode,
|
|
conversation_manager.get_models_manager(),
|
|
)
|
|
.await;
|
|
if let Some(exit_info) = exit_info {
|
|
return Ok(exit_info);
|
|
}
|
|
if let Some(updated_model) = config.model.clone() {
|
|
model = updated_model;
|
|
}
|
|
|
|
let skills_outcome = load_skills(&config);
|
|
if !skills_outcome.errors.is_empty() {
|
|
match run_skill_error_prompt(tui, &skills_outcome.errors).await {
|
|
SkillErrorPromptOutcome::Exit => {
|
|
return Ok(AppExitInfo {
|
|
token_usage: TokenUsage::default(),
|
|
conversation_id: None,
|
|
update_action: None,
|
|
});
|
|
}
|
|
SkillErrorPromptOutcome::Continue => {}
|
|
}
|
|
}
|
|
|
|
let skills = if config.features.enabled(Feature::Skills) {
|
|
Some(skills_outcome.skills.clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
|
let model_family = conversation_manager
|
|
.get_models_manager()
|
|
.construct_model_family(model.as_str(), &config)
|
|
.await;
|
|
let mut chat_widget = match resume_selection {
|
|
ResumeSelection::StartFresh | ResumeSelection::Exit => {
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
initial_prompt: initial_prompt.clone(),
|
|
initial_images: initial_images.clone(),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: conversation_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
skills: skills.clone(),
|
|
is_first_run,
|
|
model_family: model_family.clone(),
|
|
};
|
|
ChatWidget::new(init, conversation_manager.clone())
|
|
}
|
|
ResumeSelection::Resume(path) => {
|
|
let resumed = conversation_manager
|
|
.resume_conversation_from_rollout(
|
|
config.clone(),
|
|
path.clone(),
|
|
auth_manager.clone(),
|
|
)
|
|
.await
|
|
.wrap_err_with(|| {
|
|
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_prompt: initial_prompt.clone(),
|
|
initial_images: initial_images.clone(),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: conversation_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
skills: skills.clone(),
|
|
is_first_run,
|
|
model_family: model_family.clone(),
|
|
};
|
|
ChatWidget::new_from_existing(
|
|
init,
|
|
resumed.conversation,
|
|
resumed.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: conversation_manager.clone(),
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager: auth_manager.clone(),
|
|
config,
|
|
current_model: model.clone(),
|
|
active_profile,
|
|
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(),
|
|
feedback: feedback.clone(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
skip_world_writable_scan_once: false,
|
|
skills,
|
|
};
|
|
|
|
// 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 = codex_core::get_platform_sandbox().is_some()
|
|
&& matches!(
|
|
app.config.sandbox_policy,
|
|
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.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 {
|
|
app.handle_event(
|
|
tui,
|
|
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
|
|
latest_version,
|
|
crate::update_action::get_update_action(),
|
|
))),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let tui_events = tui.event_stream();
|
|
tokio::pin!(tui_events);
|
|
|
|
tui.frame_requester().schedule_frame();
|
|
|
|
while select! {
|
|
Some(event) = app_event_rx.recv() => {
|
|
app.handle_event(tui, event).await?
|
|
}
|
|
Some(event) = tui_events.next() => {
|
|
app.handle_tui_event(tui, event).await?
|
|
}
|
|
} {}
|
|
tui.terminal.clear()?;
|
|
Ok(AppExitInfo {
|
|
token_usage: app.token_usage(),
|
|
conversation_id: app.chat_widget.conversation_id(),
|
|
update_action: app.pending_update_action,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn handle_tui_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
event: TuiEvent,
|
|
) -> Result<bool> {
|
|
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 => {
|
|
self.chat_widget.maybe_post_pending_notification(tui);
|
|
if self
|
|
.chat_widget
|
|
.handle_paste_burst_tick(tui.frame_requester())
|
|
{
|
|
return Ok(true);
|
|
}
|
|
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));
|
|
}
|
|
},
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
|
let model_family = self
|
|
.server
|
|
.get_models_manager()
|
|
.construct_model_family(self.current_model.as_str(), &self.config)
|
|
.await;
|
|
match event {
|
|
AppEvent::NewSession => {
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.conversation_id(),
|
|
);
|
|
self.shutdown_current_conversation().await;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: self.config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: self.app_event_tx.clone(),
|
|
initial_prompt: None,
|
|
initial_images: Vec::new(),
|
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
|
auth_manager: self.auth_manager.clone(),
|
|
models_manager: self.server.get_models_manager(),
|
|
feedback: self.feedback.clone(),
|
|
skills: self.skills.clone(),
|
|
is_first_run: false,
|
|
model_family: model_family.clone(),
|
|
};
|
|
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
|
self.current_model = model_family.get_model_slug().to_string();
|
|
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?
|
|
{
|
|
ResumeSelection::Resume(path) => {
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.conversation_id(),
|
|
);
|
|
match self
|
|
.server
|
|
.resume_conversation_from_rollout(
|
|
self.config.clone(),
|
|
path.clone(),
|
|
self.auth_manager.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(resumed) => {
|
|
self.shutdown_current_conversation().await;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: self.config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: self.app_event_tx.clone(),
|
|
initial_prompt: None,
|
|
initial_images: Vec::new(),
|
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
|
auth_manager: self.auth_manager.clone(),
|
|
models_manager: self.server.get_models_manager(),
|
|
feedback: self.feedback.clone(),
|
|
skills: self.skills.clone(),
|
|
is_first_run: false,
|
|
model_family: model_family.clone(),
|
|
};
|
|
self.chat_widget = ChatWidget::new_from_existing(
|
|
init,
|
|
resumed.conversation,
|
|
resumed.session_configured,
|
|
);
|
|
self.current_model = model_family.get_model_slug().to_string();
|
|
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) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to resume session from {}: {err}",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
ResumeSelection::Exit | ResumeSelection::StartFresh => {}
|
|
}
|
|
|
|
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
|
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) => {
|
|
if self.suppress_shutdown_complete
|
|
&& matches!(event.msg, EventMsg::ShutdownComplete)
|
|
{
|
|
self.suppress_shutdown_complete = false;
|
|
return Ok(true);
|
|
}
|
|
self.chat_widget.handle_codex_event(event);
|
|
}
|
|
AppEvent::ConversationHistory(ev) => {
|
|
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
|
}
|
|
AppEvent::ExitRequest => {
|
|
return Ok(false);
|
|
}
|
|
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) => {
|
|
if !query.is_empty() {
|
|
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) => {
|
|
let model_family = self
|
|
.server
|
|
.get_models_manager()
|
|
.construct_model_family(&model, &self.config)
|
|
.await;
|
|
self.chat_widget.set_model(&model, model_family);
|
|
self.current_model = model;
|
|
}
|
|
AppEvent::OpenReasoningPopup { model } => {
|
|
self.chat_widget.open_reasoning_popup(model);
|
|
}
|
|
AppEvent::OpenAllModelsPopup { models } => {
|
|
self.chat_widget.open_all_models_popup(models);
|
|
}
|
|
AppEvent::OpenFullAccessConfirmation { preset } => {
|
|
self.chat_widget.open_full_access_confirmation(preset);
|
|
}
|
|
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::OpenWindowsSandboxEnablePrompt { preset } => {
|
|
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
|
|
}
|
|
AppEvent::EnableWindowsSandboxForAgentMode { preset } => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let profile = self.active_profile.as_deref();
|
|
let feature_key = Feature::WindowsSandbox.key();
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_feature_enabled(feature_key, true)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.config.set_windows_sandbox_globally(true);
|
|
self.chat_widget.clear_forced_auto_mode_downgrade();
|
|
if let Some((sample_paths, extra_count, failed_scan)) =
|
|
self.chat_widget.world_writable_warning_details()
|
|
{
|
|
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()),
|
|
model: None,
|
|
effort: None,
|
|
summary: 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(
|
|
"Enabled experimental Windows 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;
|
|
}
|
|
}
|
|
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::UpdateAskForApprovalPolicy(policy) => {
|
|
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
|
|
);
|
|
|
|
self.config.sandbox_policy = policy.clone();
|
|
#[cfg(target_os = "windows")]
|
|
if !matches!(policy, codex_core::protocol::SandboxPolicy::ReadOnly)
|
|
|| codex_core::get_platform_sandbox().is_some()
|
|
{
|
|
self.config.forced_auto_mode_downgraded_on_windows = false;
|
|
}
|
|
self.chat_widget.set_sandbox_policy(policy);
|
|
|
|
// 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.skip_world_writable_scan_once {
|
|
self.skip_world_writable_scan_once = false;
|
|
return Ok(true);
|
|
}
|
|
|
|
let should_check = codex_core::get_platform_sandbox().is_some()
|
|
&& 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.clone();
|
|
Self::spawn_world_writable_scan(
|
|
cwd,
|
|
env_map,
|
|
logs_base_dir,
|
|
sandbox_policy,
|
|
tx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SkipNextWorldWritableScan => {
|
|
self.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 { migration_config } => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_model_migration_prompt(&migration_config, true)
|
|
.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::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::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(true)
|
|
}
|
|
|
|
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>) {
|
|
self.chat_widget.set_reasoning_effort(effort);
|
|
self.config.model_reasoning_effort = effort;
|
|
}
|
|
|
|
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();
|
|
}
|
|
// 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() =>
|
|
{
|
|
// Delegate to helper for clarity; preserves behavior.
|
|
self.confirm_backtrack_from_main();
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn migration_prompt_allowed_auth_modes(migration_config_key: &str) -> Option<&'static [AuthMode]> {
|
|
match migration_config_key {
|
|
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_MIGRATION_AUTH_MODES),
|
|
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_CODEX_MIGRATION_AUTH_MODES),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn migration_prompt_allows_auth_mode(
|
|
auth_mode: Option<AuthMode>,
|
|
migration_config_key: &str,
|
|
) -> bool {
|
|
if let Some(allowed_modes) = migration_prompt_allowed_auth_modes(migration_config_key) {
|
|
match auth_mode {
|
|
None => true,
|
|
Some(mode) => allowed_modes.contains(&mode),
|
|
}
|
|
} else {
|
|
auth_mode != Some(AuthMode::ApiKey)
|
|
}
|
|
}
|
|
|
|
#[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::ConversationManager;
|
|
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_protocol::ConversationId;
|
|
use ratatui::prelude::Line;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
fn make_test_app() -> App {
|
|
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender();
|
|
let config = chat_widget.config_ref().clone();
|
|
let current_model = chat_widget.get_model_family().get_model_slug().to_string();
|
|
let server = Arc::new(ConversationManager::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());
|
|
|
|
App {
|
|
server,
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager,
|
|
config,
|
|
current_model,
|
|
active_profile: 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(),
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
skip_world_writable_scan_once: false,
|
|
skills: None,
|
|
}
|
|
}
|
|
|
|
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();
|
|
let config = chat_widget.config_ref().clone();
|
|
let current_model = chat_widget.get_model_family().get_model_slug().to_string();
|
|
let server = Arc::new(ConversationManager::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());
|
|
|
|
(
|
|
App {
|
|
server,
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager,
|
|
config,
|
|
current_model,
|
|
active_profile: 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(),
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
skip_world_writable_scan_once: false,
|
|
skills: None,
|
|
},
|
|
rx,
|
|
op_rx,
|
|
)
|
|
}
|
|
|
|
fn all_model_presets() -> Vec<ModelPreset> {
|
|
codex_core::openai_models::model_presets::all_model_presets().clone()
|
|
}
|
|
|
|
#[test]
|
|
fn model_migration_prompt_only_shows_for_deprecated_models() {
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5",
|
|
"gpt-5.1",
|
|
None,
|
|
all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex",
|
|
"gpt-5.1-codex",
|
|
None,
|
|
all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex-mini",
|
|
"gpt-5.1-codex-mini",
|
|
None,
|
|
all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex-max",
|
|
None,
|
|
all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex",
|
|
None,
|
|
all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn model_migration_prompt_respects_hide_flag_and_self_target() {
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5",
|
|
"gpt-5.1",
|
|
Some(true),
|
|
all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1",
|
|
"gpt-5.1",
|
|
None,
|
|
all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn update_reasoning_effort_updates_config() {
|
|
let mut app = make_test_app();
|
|
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium);
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
|
|
|
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
|
|
assert_eq!(
|
|
app.config.model_reasoning_effort,
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().model_reasoning_effort,
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
|
let mut app = make_test_app();
|
|
|
|
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
}) 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: ConversationId::new(),
|
|
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,
|
|
skill_load_outcome: None,
|
|
rollout_path: PathBuf::new(),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.current_model.as_str(),
|
|
event,
|
|
is_first,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
// Simulate the transcript after trimming for a fork, replaying history, and
|
|
// appending the edited turn. The session header separates the retained history
|
|
// from the forked conversation's replayed turns.
|
|
app.transcript_cells = vec![
|
|
make_header(true),
|
|
user_cell("first question"),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up"),
|
|
agent_cell("answer follow-up"),
|
|
make_header(false),
|
|
user_cell("first question"),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up (edited)"),
|
|
agent_cell("answer edited"),
|
|
];
|
|
|
|
assert_eq!(user_count(&app.transcript_cells), 2);
|
|
|
|
app.backtrack.base_id = Some(ConversationId::new());
|
|
app.backtrack.primed = true;
|
|
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
|
|
|
app.confirm_backtrack_from_main();
|
|
|
|
let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack");
|
|
assert_eq!(nth, 1);
|
|
assert_eq!(prefill, "follow-up (edited)");
|
|
}
|
|
|
|
#[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();
|
|
|
|
let conversation_id = ConversationId::new();
|
|
let event = SessionConfiguredEvent {
|
|
session_id: conversation_id,
|
|
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,
|
|
skill_load_outcome: None,
|
|
rollout_path: 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_conversation().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"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn session_summary_skip_zero_usage() {
|
|
assert!(session_summary(TokenUsage::default(), None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn session_summary_includes_resume_hint() {
|
|
let usage = TokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 2,
|
|
total_tokens: 12,
|
|
..Default::default()
|
|
};
|
|
let conversation =
|
|
ConversationId::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())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gpt5_migration_allows_api_key_and_chatgpt() {
|
|
assert!(migration_prompt_allows_auth_mode(
|
|
Some(AuthMode::ApiKey),
|
|
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG,
|
|
));
|
|
assert!(migration_prompt_allows_auth_mode(
|
|
Some(AuthMode::ChatGPT),
|
|
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn gpt_5_1_codex_max_migration_limits_to_chatgpt() {
|
|
assert!(migration_prompt_allows_auth_mode(
|
|
Some(AuthMode::ChatGPT),
|
|
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
|
|
));
|
|
assert!(migration_prompt_allows_auth_mode(
|
|
Some(AuthMode::ApiKey),
|
|
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn other_migrations_block_api_key() {
|
|
assert!(!migration_prompt_allows_auth_mode(
|
|
Some(AuthMode::ApiKey),
|
|
"unknown"
|
|
));
|
|
assert!(migration_prompt_allows_auth_mode(
|
|
Some(AuthMode::ChatGPT),
|
|
"unknown"
|
|
));
|
|
}
|
|
}
|