## Why The argument-comment lint now has a packaged DotSlash artifact from [#15198](https://github.com/openai/codex/pull/15198), so the normal repo lint path should use that released payload instead of rebuilding the lint from source every time. That keeps `just clippy` and CI aligned with the shipped artifact while preserving a separate source-build path for people actively hacking on the lint crate. The current alpha package also exposed two integration wrinkles that the repo-side prebuilt wrapper needs to smooth over: - the bundled Dylint library filename includes the host triple, for example `@nightly-2025-09-18-aarch64-apple-darwin`, and Dylint derives `RUSTUP_TOOLCHAIN` from that filename - on Windows, Dylint's driver path also expects `RUSTUP_HOME` to be present in the environment Without those adjustments, the prebuilt CI jobs fail during `cargo metadata` or driver setup. This change makes the checked-in prebuilt wrapper normalize the packaged library name to the plain `nightly-2025-09-18` channel before invoking `cargo-dylint`, and it teaches both the wrapper and the packaged runner source to infer `RUSTUP_HOME` from `rustup show home` when the environment does not already provide it. After the prebuilt Windows lint job started running successfully, it also surfaced a handful of existing anonymous literal callsites in `windows-sandbox-rs`. This PR now annotates those callsites so the new cross-platform lint job is green on the current tree. ## What Changed - checked in the current `tools/argument-comment-lint/argument-comment-lint` DotSlash manifest - kept `tools/argument-comment-lint/run.sh` as the source-build wrapper for lint development - added `tools/argument-comment-lint/run-prebuilt-linter.sh` as the normal enforcement path, using the checked-in DotSlash package and bundled `cargo-dylint` - updated `just clippy` and `just argument-comment-lint` to use the prebuilt wrapper - split `.github/workflows/rust-ci.yml` so source-package checks live in a dedicated `argument_comment_lint_package` job, while the released lint runs in an `argument_comment_lint_prebuilt` matrix on Linux, macOS, and Windows - kept the pinned `nightly-2025-09-18` toolchain install in the prebuilt CI matrix, since the prebuilt package still relies on rustup-provided toolchain components - updated `tools/argument-comment-lint/run-prebuilt-linter.sh` to normalize host-qualified nightly library filenames, keep the `rustup` shim directory ahead of direct toolchain `cargo` binaries, and export `RUSTUP_HOME` when needed for Windows Dylint driver setup - updated `tools/argument-comment-lint/src/bin/argument-comment-lint.rs` so future published DotSlash artifacts apply the same nightly-filename normalization and `RUSTUP_HOME` inference internally - fixed the remaining Windows lint violations in `codex-rs/windows-sandbox-rs` by adding the required `/*param*/` comments at the reported callsites - documented the checked-in DotSlash file, wrapper split, archive layout, nightly prerequisite, and Windows `RUSTUP_HOME` requirement in `tools/argument-comment-lint/README.md`
7785 lines
313 KiB
Rust
7785 lines
313 KiB
Rust
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event::ExitMode;
|
|
use crate::app_event::RealtimeAudioDeviceKind;
|
|
#[cfg(target_os = "windows")]
|
|
use crate::app_event::WindowsSandboxEnableMode;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::bottom_pane::ApprovalRequest;
|
|
use crate::bottom_pane::FeedbackAudience;
|
|
use crate::bottom_pane::McpServerElicitationFormRequest;
|
|
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::chatwidget::ThreadInputState;
|
|
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::multi_agents::agent_picker_status_dot_spans;
|
|
use crate::multi_agents::format_agent_picker_item_name;
|
|
use crate::multi_agents::next_agent_shortcut_matches;
|
|
use crate::multi_agents::previous_agent_shortcut_matches;
|
|
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 crate::version::CODEX_CLI_VERSION;
|
|
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::types::ApprovalsReviewer;
|
|
use codex_core::config::types::ModelAvailabilityNuxConfig;
|
|
use codex_core::config_loader::ConfigLayerStackOrdering;
|
|
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
|
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;
|
|
#[cfg(target_os = "windows")]
|
|
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
|
use codex_features::Feature;
|
|
use codex_otel::SessionTelemetry;
|
|
use codex_otel::TelemetryAuthMode;
|
|
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::ModelAvailabilityNux;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::FinalOutput;
|
|
use codex_protocol::protocol::ListSkillsResponseEvent;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionConfiguredEvent;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SkillErrorInfo;
|
|
use codex_protocol::protocol::TokenUsage;
|
|
use codex_terminal_detection::user_agent;
|
|
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 tokio::task::JoinHandle;
|
|
use toml::Value as TomlValue;
|
|
|
|
mod agent_navigation;
|
|
mod pending_interactive_replay;
|
|
|
|
use self::agent_navigation::AgentNavigationDirection;
|
|
use self::agent_navigation::AgentNavigationState;
|
|
use self::pending_interactive_replay::PendingInteractiveReplayState;
|
|
|
|
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
|
|
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768;
|
|
|
|
enum ThreadInteractiveRequest {
|
|
Approval(ApprovalRequest),
|
|
McpServerElicitation(McpServerElicitationFormRequest),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct GuardianApprovalsMode {
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: ApprovalsReviewer,
|
|
sandbox_policy: SandboxPolicy,
|
|
}
|
|
|
|
/// Enabling the Guardian Approvals experiment in the TUI should also switch
|
|
/// the current `/approvals` settings to the matching Guardian Approvals mode.
|
|
/// Users
|
|
/// can still change `/approvals` afterward; this just assumes that opting into
|
|
/// the experiment means they want guardian review enabled immediately.
|
|
fn guardian_approvals_mode() -> GuardianApprovalsMode {
|
|
GuardianApprovalsMode {
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: ApprovalsReviewer::GuardianSubagent,
|
|
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
|
}
|
|
}
|
|
/// Baseline cadence for periodic stream commit animation ticks.
|
|
///
|
|
/// Smooth-mode streaming drains one line per tick, so this interval controls
|
|
/// perceived typing speed for non-backlogged output.
|
|
const COMMIT_ANIMATION_TICK: Duration = tui::TARGET_FRAME_INTERVAL;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppExitInfo {
|
|
pub token_usage: TokenUsage,
|
|
pub thread_id: Option<ThreadId>,
|
|
pub thread_name: Option<String>,
|
|
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,
|
|
thread_name: 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>,
|
|
thread_name: Option<String>,
|
|
) -> Option<SessionSummary> {
|
|
if token_usage.is_zero() {
|
|
return None;
|
|
}
|
|
|
|
let usage_line = FinalOutput::from(token_usage).to_string();
|
|
let resume_command = codex_core::util::resume_command(thread_name.as_deref(), 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_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,
|
|
/*include_disabled*/ 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),
|
|
)));
|
|
}
|
|
|
|
fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) {
|
|
let Some(message) = codex_core::config::missing_system_bwrap_warning() else {
|
|
return;
|
|
};
|
|
|
|
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
history_cell::new_warning_event(message),
|
|
)));
|
|
}
|
|
|
|
async fn emit_custom_prompt_deprecation_notice(app_event_tx: &AppEventSender, codex_home: &Path) {
|
|
let prompts_dir = codex_home.join("prompts");
|
|
let prompt_count = codex_core::custom_prompts::discover_prompts_in(&prompts_dir)
|
|
.await
|
|
.len();
|
|
if prompt_count == 0 {
|
|
return;
|
|
}
|
|
|
|
let prompt_label = if prompt_count == 1 {
|
|
"prompt"
|
|
} else {
|
|
"prompts"
|
|
};
|
|
let details = format!(
|
|
"Detected {prompt_count} custom {prompt_label} in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into a skill."
|
|
);
|
|
|
|
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
|
history_cell::new_deprecation_notice(
|
|
"Custom prompts are deprecated and will soon be removed.".to_string(),
|
|
Some(details),
|
|
),
|
|
)));
|
|
}
|
|
|
|
#[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>,
|
|
input_state: Option<ThreadInputState>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ThreadEventStore {
|
|
session_configured: Option<Event>,
|
|
buffer: VecDeque<Event>,
|
|
user_message_ids: HashSet<String>,
|
|
pending_interactive_replay: PendingInteractiveReplayState,
|
|
input_state: Option<ThreadInputState>,
|
|
capacity: usize,
|
|
active: bool,
|
|
}
|
|
|
|
impl ThreadEventStore {
|
|
fn new(capacity: usize) -> Self {
|
|
Self {
|
|
session_configured: None,
|
|
buffer: VecDeque::new(),
|
|
user_message_ids: HashSet::new(),
|
|
pending_interactive_replay: PendingInteractiveReplayState::default(),
|
|
input_state: None,
|
|
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) {
|
|
self.pending_interactive_replay.note_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()
|
|
{
|
|
self.pending_interactive_replay.note_evicted_event(&removed);
|
|
if 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(),
|
|
// Thread switches replay buffered events into a rebuilt ChatWidget. Only replay
|
|
// interactive prompts that are still pending, or answered approvals/input will reappear.
|
|
events: self
|
|
.buffer
|
|
.iter()
|
|
.filter(|event| {
|
|
self.pending_interactive_replay
|
|
.should_replay_snapshot_event(event)
|
|
})
|
|
.cloned()
|
|
.collect(),
|
|
input_state: self.input_state.clone(),
|
|
}
|
|
}
|
|
|
|
fn note_outbound_op(&mut self, op: &Op) {
|
|
self.pending_interactive_replay.note_outbound_op(op);
|
|
}
|
|
|
|
fn op_can_change_pending_replay_state(op: &Op) -> bool {
|
|
PendingInteractiveReplayState::op_can_change_state(op)
|
|
}
|
|
|
|
fn event_can_change_pending_thread_approvals(event: &Event) -> bool {
|
|
PendingInteractiveReplayState::event_can_change_pending_thread_approvals(event)
|
|
}
|
|
|
|
fn has_pending_thread_approvals(&self) -> bool {
|
|
self.pending_interactive_replay
|
|
.has_pending_thread_approvals()
|
|
}
|
|
}
|
|
|
|
#[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 == target_model && preset.show_in_picker)
|
|
{
|
|
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 && preset.show_in_picker)
|
|
}
|
|
|
|
const MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT: u32 = 4;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct StartupTooltipOverride {
|
|
model_slug: String,
|
|
message: String,
|
|
}
|
|
|
|
fn select_model_availability_nux(
|
|
available_models: &[ModelPreset],
|
|
nux_config: &ModelAvailabilityNuxConfig,
|
|
) -> Option<StartupTooltipOverride> {
|
|
available_models.iter().find_map(|preset| {
|
|
let ModelAvailabilityNux { message } = preset.availability_nux.as_ref()?;
|
|
let shown_count = nux_config
|
|
.shown_count
|
|
.get(&preset.model)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
(shown_count < MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT).then(|| StartupTooltipOverride {
|
|
model_slug: preset.model.clone(),
|
|
message: message.clone(),
|
|
})
|
|
})
|
|
}
|
|
|
|
async fn prepare_startup_tooltip_override(
|
|
config: &mut Config,
|
|
available_models: &[ModelPreset],
|
|
is_first_run: bool,
|
|
) -> Option<String> {
|
|
if is_first_run || !config.show_tooltips {
|
|
return None;
|
|
}
|
|
|
|
let tooltip_override =
|
|
select_model_availability_nux(available_models, &config.model_availability_nux)?;
|
|
|
|
let shown_count = config
|
|
.model_availability_nux
|
|
.shown_count
|
|
.get(&tooltip_override.model_slug)
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let next_count = shown_count.saturating_add(1);
|
|
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
|
|
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
|
|
|
|
if let Err(err) = ConfigEditsBuilder::new(&config.codex_home)
|
|
.set_model_availability_nux_count(&updated_shown_count)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
model = %tooltip_override.model_slug,
|
|
"failed to persist model availability nux count"
|
|
);
|
|
return Some(tooltip_override.message);
|
|
}
|
|
|
|
config.model_availability_nux.shown_count = updated_shown_count;
|
|
Some(tooltip_override.message)
|
|
}
|
|
|
|
async fn handle_model_migration_prompt_if_needed(
|
|
tui: &mut tui::Tui,
|
|
config: &mut Config,
|
|
model: &str,
|
|
app_event_tx: &AppEventSender,
|
|
available_models: &[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,
|
|
thread_name: None,
|
|
update_action: None,
|
|
exit_reason: ExitReason::UserRequested,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) struct App {
|
|
pub(crate) server: Arc<ThreadManager>,
|
|
pub(crate) session_telemetry: SessionTelemetry,
|
|
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>,
|
|
// Shared across ChatWidget instances so invalid status-line config warnings only emit once.
|
|
status_line_invalid_items_warned: Arc<AtomicBool>,
|
|
// Shared across ChatWidget instances so invalid terminal-title config warnings only emit once.
|
|
terminal_title_invalid_items_warned: 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,
|
|
feedback_audience: FeedbackAudience,
|
|
/// Set when the user confirms an update; propagated on exit.
|
|
pub(crate) pending_update_action: Option<UpdateAction>,
|
|
|
|
/// One-shot guard used while switching threads.
|
|
///
|
|
/// We set this when intentionally stopping the current thread before moving
|
|
/// to another one, then ignore exactly one `ShutdownComplete` so it is not
|
|
/// misclassified as an unexpected sub-agent death.
|
|
suppress_shutdown_complete: bool,
|
|
/// Tracks the thread we intentionally shut down while exiting the app.
|
|
///
|
|
/// When this matches the active thread, its `ShutdownComplete` should lead to
|
|
/// process exit instead of being treated as an unexpected sub-agent death that
|
|
/// triggers failover to the primary thread.
|
|
///
|
|
/// This is thread-scoped state (`Option<ThreadId>`) instead of a global bool
|
|
/// so shutdown events from other threads still take the normal failover path.
|
|
pending_shutdown_exit_thread_id: Option<ThreadId>,
|
|
|
|
windows_sandbox: WindowsSandboxState,
|
|
|
|
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
|
|
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
|
|
agent_navigation: AgentNavigationState,
|
|
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,
|
|
feedback_audience: self.feedback_audience,
|
|
model: Some(self.chat_widget.current_model().to_string()),
|
|
startup_tooltip_override: None,
|
|
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
|
|
session_telemetry: self.session_telemetry.clone(),
|
|
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.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}"))
|
|
}
|
|
|
|
async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> {
|
|
let mut config = self
|
|
.rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.clone())
|
|
.await?;
|
|
self.apply_runtime_policy_overrides(&mut config);
|
|
self.config = config;
|
|
Ok(())
|
|
}
|
|
|
|
async fn refresh_in_memory_config_from_disk_best_effort(&mut self, action: &str) {
|
|
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
|
tracing::warn!(
|
|
error = %err,
|
|
action,
|
|
"failed to refresh config before thread transition; continuing with current in-memory config"
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn rebuild_config_for_resume_or_fallback(
|
|
&mut self,
|
|
current_cwd: &Path,
|
|
resume_cwd: PathBuf,
|
|
) -> Result<Config> {
|
|
match self.rebuild_config_for_cwd(resume_cwd.clone()).await {
|
|
Ok(config) => Ok(config),
|
|
Err(err) => {
|
|
if crate::cwds_differ(current_cwd, &resume_cwd) {
|
|
Err(err)
|
|
} else {
|
|
let resume_cwd_display = resume_cwd.display().to_string();
|
|
tracing::warn!(
|
|
error = %err,
|
|
cwd = %resume_cwd_display,
|
|
"failed to rebuild config for same-cwd resume; using current in-memory config"
|
|
);
|
|
Ok(self.config.clone())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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.permissions.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.permissions.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}"
|
|
));
|
|
}
|
|
}
|
|
|
|
fn set_approvals_reviewer_in_app_and_widget(&mut self, reviewer: ApprovalsReviewer) {
|
|
self.config.approvals_reviewer = reviewer;
|
|
self.chat_widget.set_approvals_reviewer(reviewer);
|
|
}
|
|
|
|
fn try_set_approval_policy_on_config(
|
|
&mut self,
|
|
config: &mut Config,
|
|
policy: AskForApproval,
|
|
user_message_prefix: &str,
|
|
log_message: &str,
|
|
) -> bool {
|
|
if let Err(err) = config.permissions.approval_policy.set(policy) {
|
|
tracing::warn!(error = %err, "{log_message}");
|
|
self.chat_widget
|
|
.add_error_message(format!("{user_message_prefix}: {err}"));
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn try_set_sandbox_policy_on_config(
|
|
&mut self,
|
|
config: &mut Config,
|
|
policy: SandboxPolicy,
|
|
user_message_prefix: &str,
|
|
log_message: &str,
|
|
) -> bool {
|
|
if let Err(err) = config.permissions.sandbox_policy.set(policy) {
|
|
tracing::warn!(error = %err, "{log_message}");
|
|
self.chat_widget
|
|
.add_error_message(format!("{user_message_prefix}: {err}"));
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) {
|
|
if updates.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let guardian_approvals_preset = guardian_approvals_mode();
|
|
let mut next_config = self.config.clone();
|
|
let active_profile = self.active_profile.clone();
|
|
let scoped_segments = |key: &str| {
|
|
if let Some(profile) = active_profile.as_deref() {
|
|
vec!["profiles".to_string(), profile.to_string(), key.to_string()]
|
|
} else {
|
|
vec![key.to_string()]
|
|
}
|
|
};
|
|
let windows_sandbox_changed = updates.iter().any(|(feature, _)| {
|
|
matches!(
|
|
feature,
|
|
Feature::WindowsSandbox | Feature::WindowsSandboxElevated
|
|
)
|
|
});
|
|
let mut approval_policy_override = None;
|
|
let mut approvals_reviewer_override = None;
|
|
let mut sandbox_policy_override = None;
|
|
let mut feature_updates_to_apply = Vec::with_capacity(updates.len());
|
|
// Guardian Approvals owns `approvals_reviewer`, but disabling the
|
|
// feature from inside a profile should not silently clear a value
|
|
// configured at the root scope.
|
|
let (root_approvals_reviewer_blocks_profile_disable, profile_approvals_reviewer_configured) = {
|
|
let effective_config = next_config.config_layer_stack.effective_config();
|
|
let root_blocks_disable = effective_config
|
|
.as_table()
|
|
.and_then(|table| table.get("approvals_reviewer"))
|
|
.is_some_and(|value| value != &TomlValue::String("user".to_string()));
|
|
let profile_configured = active_profile.as_deref().is_some_and(|profile| {
|
|
effective_config
|
|
.as_table()
|
|
.and_then(|table| table.get("profiles"))
|
|
.and_then(TomlValue::as_table)
|
|
.and_then(|profiles| profiles.get(profile))
|
|
.and_then(TomlValue::as_table)
|
|
.is_some_and(|profile_config| profile_config.contains_key("approvals_reviewer"))
|
|
});
|
|
(root_blocks_disable, profile_configured)
|
|
};
|
|
let mut permissions_history_label: Option<&'static str> = None;
|
|
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();
|
|
let mut feature_edits = Vec::new();
|
|
if feature == Feature::GuardianApproval
|
|
&& !enabled
|
|
&& self.active_profile.is_some()
|
|
&& root_approvals_reviewer_blocks_profile_disable
|
|
{
|
|
self.chat_widget.add_error_message(
|
|
"Cannot disable Guardian Approvals in this profile because `approvals_reviewer` is configured outside the active profile.".to_string(),
|
|
);
|
|
continue;
|
|
}
|
|
let mut feature_config = next_config.clone();
|
|
if let Err(err) = feature_config.features.set_enabled(feature, enabled) {
|
|
tracing::error!(
|
|
error = %err,
|
|
feature = feature_key,
|
|
"failed to update constrained feature flags"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update experimental feature `{feature_key}`: {err}"
|
|
));
|
|
continue;
|
|
}
|
|
let effective_enabled = feature_config.features.enabled(feature);
|
|
if feature == Feature::GuardianApproval {
|
|
let previous_approvals_reviewer = feature_config.approvals_reviewer;
|
|
if effective_enabled {
|
|
// Persist the reviewer setting so future sessions keep the
|
|
// experiment's matching `/approvals` mode until the user
|
|
// changes it explicitly.
|
|
feature_config.approvals_reviewer =
|
|
guardian_approvals_preset.approvals_reviewer;
|
|
feature_edits.push(ConfigEdit::SetPath {
|
|
segments: scoped_segments("approvals_reviewer"),
|
|
value: guardian_approvals_preset
|
|
.approvals_reviewer
|
|
.to_string()
|
|
.into(),
|
|
});
|
|
if previous_approvals_reviewer != guardian_approvals_preset.approvals_reviewer {
|
|
permissions_history_label = Some("Guardian Approvals");
|
|
}
|
|
} else if !effective_enabled {
|
|
if profile_approvals_reviewer_configured || self.active_profile.is_none() {
|
|
feature_edits.push(ConfigEdit::ClearPath {
|
|
segments: scoped_segments("approvals_reviewer"),
|
|
});
|
|
}
|
|
feature_config.approvals_reviewer = ApprovalsReviewer::User;
|
|
if previous_approvals_reviewer != ApprovalsReviewer::User {
|
|
permissions_history_label = Some("Default");
|
|
}
|
|
}
|
|
approvals_reviewer_override = Some(feature_config.approvals_reviewer);
|
|
}
|
|
if feature == Feature::GuardianApproval && effective_enabled {
|
|
// The feature flag alone is not enough for the live session.
|
|
// We also align approval policy + sandbox to the Guardian
|
|
// Approvals preset so enabling the experiment immediately
|
|
// makes guardian review observable in the current thread.
|
|
if !self.try_set_approval_policy_on_config(
|
|
&mut feature_config,
|
|
guardian_approvals_preset.approval_policy,
|
|
"Failed to enable Guardian Approvals",
|
|
"failed to set guardian approvals approval policy on staged config",
|
|
) {
|
|
continue;
|
|
}
|
|
if !self.try_set_sandbox_policy_on_config(
|
|
&mut feature_config,
|
|
guardian_approvals_preset.sandbox_policy.clone(),
|
|
"Failed to enable Guardian Approvals",
|
|
"failed to set guardian approvals sandbox policy on staged config",
|
|
) {
|
|
continue;
|
|
}
|
|
feature_edits.extend([
|
|
ConfigEdit::SetPath {
|
|
segments: scoped_segments("approval_policy"),
|
|
value: "on-request".into(),
|
|
},
|
|
ConfigEdit::SetPath {
|
|
segments: scoped_segments("sandbox_mode"),
|
|
value: "workspace-write".into(),
|
|
},
|
|
]);
|
|
approval_policy_override = Some(guardian_approvals_preset.approval_policy);
|
|
sandbox_policy_override = Some(guardian_approvals_preset.sandbox_policy.clone());
|
|
}
|
|
next_config = feature_config;
|
|
feature_updates_to_apply.push((feature, effective_enabled));
|
|
builder = builder
|
|
.with_edits(feature_edits)
|
|
.set_feature_enabled(feature_key, effective_enabled);
|
|
}
|
|
|
|
// Persist first so the live session does not diverge from disk if the
|
|
// config edit fails. Runtime/UI state is patched below only after the
|
|
// durable config update succeeds.
|
|
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}"));
|
|
return;
|
|
}
|
|
|
|
self.config = next_config;
|
|
for (feature, effective_enabled) in feature_updates_to_apply {
|
|
self.chat_widget
|
|
.set_feature_enabled(feature, effective_enabled);
|
|
}
|
|
if approvals_reviewer_override.is_some() {
|
|
self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer);
|
|
}
|
|
if approval_policy_override.is_some() {
|
|
self.chat_widget
|
|
.set_approval_policy(self.config.permissions.approval_policy.value());
|
|
}
|
|
if sandbox_policy_override.is_some()
|
|
&& let Err(err) = self
|
|
.chat_widget
|
|
.set_sandbox_policy(self.config.permissions.sandbox_policy.get().clone())
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to set guardian approvals sandbox policy on chat config"
|
|
);
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to enable Guardian Approvals: {err}"));
|
|
}
|
|
|
|
if approval_policy_override.is_some()
|
|
|| approvals_reviewer_override.is_some()
|
|
|| sandbox_policy_override.is_some()
|
|
{
|
|
// This uses `OverrideTurnContext` intentionally: toggling the
|
|
// experiment should update the active thread's effective approval
|
|
// settings immediately, just like a `/approvals` selection. Without
|
|
// this runtime patch, the config edit would only affect future
|
|
// sessions or turns recreated from disk.
|
|
let op = Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: approval_policy_override,
|
|
approvals_reviewer: approvals_reviewer_override,
|
|
sandbox_policy: sandbox_policy_override,
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
};
|
|
let replay_state_op =
|
|
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
|
|
let submitted = self.chat_widget.submit_op(op);
|
|
if submitted && let Some(op) = replay_state_op.as_ref() {
|
|
self.note_active_thread_outbound_op(op).await;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
}
|
|
|
|
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,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: Some(windows_sandbox_level),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
}));
|
|
}
|
|
}
|
|
|
|
if let Some(label) = permissions_history_label {
|
|
self.chat_widget.add_info_message(
|
|
format!("Permissions updated to {label}"),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn open_url_in_browser(&mut self, url: String) {
|
|
if let Err(err) = webbrowser::open(&url) {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to open browser for {url}: {err}"));
|
|
return;
|
|
}
|
|
|
|
self.chat_widget
|
|
.add_info_message(format!("Opened {url} in your browser."), /*hint*/ None);
|
|
}
|
|
|
|
fn clear_ui_header_lines_with_version(
|
|
&self,
|
|
width: u16,
|
|
version: &'static str,
|
|
) -> Vec<Line<'static>> {
|
|
history_cell::SessionHeaderHistoryCell::new(
|
|
self.chat_widget.current_model().to_string(),
|
|
self.chat_widget.current_reasoning_effort(),
|
|
self.chat_widget.should_show_fast_status(
|
|
self.chat_widget.current_model(),
|
|
self.chat_widget.current_service_tier(),
|
|
),
|
|
self.config.cwd.clone(),
|
|
version,
|
|
)
|
|
.display_lines(width)
|
|
}
|
|
|
|
fn clear_ui_header_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION)
|
|
}
|
|
|
|
fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) {
|
|
let width = tui.terminal.last_known_screen_size.width;
|
|
let header_lines = self.clear_ui_header_lines(width);
|
|
if !header_lines.is_empty() {
|
|
tui.insert_history_lines(header_lines);
|
|
self.has_emitted_history_lines = true;
|
|
}
|
|
}
|
|
|
|
fn clear_terminal_ui(&mut self, tui: &mut tui::Tui, redraw_header: bool) -> Result<()> {
|
|
let is_alt_screen_active = tui.is_alt_screen_active();
|
|
|
|
// Drop queued history insertions so stale transcript lines cannot be flushed after /clear.
|
|
tui.clear_pending_history_lines();
|
|
|
|
if is_alt_screen_active {
|
|
tui.terminal.clear_visible_screen()?;
|
|
} else {
|
|
// Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and
|
|
// clear are emitted as separate backend commands. Prefer a single ANSI sequence.
|
|
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
|
|
}
|
|
|
|
let mut area = tui.terminal.viewport_area;
|
|
if area.y > 0 {
|
|
// After a full clear, anchor the inline viewport at the top and redraw a fresh header
|
|
// box. `insert_history_lines()` will shift the viewport down by the rendered height.
|
|
area.y = 0;
|
|
tui.terminal.set_viewport_area(area);
|
|
}
|
|
self.has_emitted_history_lines = false;
|
|
|
|
if redraw_header {
|
|
self.queue_clear_ui_header(tui);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reset_app_ui_state_after_clear(&mut self) {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
self.abort_thread_event_listener(thread_id);
|
|
}
|
|
}
|
|
|
|
fn abort_thread_event_listener(&mut self, thread_id: ThreadId) {
|
|
if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) {
|
|
handle.abort();
|
|
}
|
|
}
|
|
|
|
fn abort_all_thread_event_listeners(&mut self) {
|
|
for handle in self
|
|
.thread_event_listener_tasks
|
|
.drain()
|
|
.map(|(_, handle)| handle)
|
|
{
|
|
handle.abort();
|
|
}
|
|
}
|
|
|
|
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, /*active*/ 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;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
|
|
async fn store_active_thread_receiver(&mut self) {
|
|
let Some(active_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
let input_state = self.chat_widget.capture_thread_input_state();
|
|
if let Some(channel) = self.thread_event_channels.get_mut(&active_id) {
|
|
let receiver = self.active_thread_rx.take();
|
|
let mut store = channel.store.lock().await;
|
|
store.active = false;
|
|
store.input_state = input_state;
|
|
if let Some(receiver) = receiver {
|
|
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, /*active*/ false).await;
|
|
}
|
|
self.active_thread_rx = None;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
|
|
async fn note_thread_outbound_op(&mut self, thread_id: ThreadId, op: &Op) {
|
|
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
|
|
return;
|
|
};
|
|
let mut store = channel.store.lock().await;
|
|
store.note_outbound_op(op);
|
|
}
|
|
|
|
async fn note_active_thread_outbound_op(&mut self, op: &Op) {
|
|
if !ThreadEventStore::op_can_change_pending_replay_state(op) {
|
|
return;
|
|
}
|
|
let Some(thread_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
self.note_thread_outbound_op(thread_id, op).await;
|
|
}
|
|
|
|
fn thread_label(&self, thread_id: ThreadId) -> String {
|
|
let is_primary = self.primary_thread_id == Some(thread_id);
|
|
let fallback_label = if is_primary {
|
|
"Main [default]".to_string()
|
|
} else {
|
|
let thread_id = thread_id.to_string();
|
|
let short_id: String = thread_id.chars().take(8).collect();
|
|
format!("Agent ({short_id})")
|
|
};
|
|
if let Some(entry) = self.agent_navigation.get(&thread_id) {
|
|
let label = format_agent_picker_item_name(
|
|
entry.agent_nickname.as_deref(),
|
|
entry.agent_role.as_deref(),
|
|
is_primary,
|
|
);
|
|
if label == "Agent" {
|
|
let thread_id = thread_id.to_string();
|
|
let short_id: String = thread_id.chars().take(8).collect();
|
|
format!("{label} ({short_id})")
|
|
} else {
|
|
label
|
|
}
|
|
} else {
|
|
fallback_label
|
|
}
|
|
}
|
|
|
|
/// Returns the thread whose transcript is currently on screen.
|
|
///
|
|
/// `active_thread_id` is the source of truth during steady state, but the widget can briefly
|
|
/// lag behind thread bookkeeping during transitions. The footer label and adjacent-thread
|
|
/// navigation both follow what the user is actually looking at, not whichever thread most
|
|
/// recently began switching.
|
|
fn current_displayed_thread_id(&self) -> Option<ThreadId> {
|
|
self.active_thread_id.or(self.chat_widget.thread_id())
|
|
}
|
|
|
|
/// Mirrors the visible thread into the contextual footer row.
|
|
///
|
|
/// The footer sometimes shows ambient context instead of an instructional hint. In multi-agent
|
|
/// sessions, that contextual row includes the currently viewed agent label. The label is
|
|
/// intentionally hidden until there is more than one known thread so single-thread sessions do
|
|
/// not spend footer space restating that the user is already on the main conversation.
|
|
fn sync_active_agent_label(&mut self) {
|
|
let label = self
|
|
.agent_navigation
|
|
.active_agent_label(self.current_displayed_thread_id(), self.primary_thread_id);
|
|
self.chat_widget.set_active_agent_label(label);
|
|
}
|
|
|
|
async fn thread_cwd(&self, thread_id: ThreadId) -> Option<PathBuf> {
|
|
let channel = self.thread_event_channels.get(&thread_id)?;
|
|
let store = channel.store.lock().await;
|
|
match store.session_configured.as_ref().map(|event| &event.msg) {
|
|
Some(EventMsg::SessionConfigured(session)) => Some(session.cwd.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn interactive_request_for_thread_event(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
event: &Event,
|
|
) -> Option<ThreadInteractiveRequest> {
|
|
let thread_label = Some(self.thread_label(thread_id));
|
|
match &event.msg {
|
|
EventMsg::ExecApprovalRequest(ev) => {
|
|
Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec {
|
|
thread_id,
|
|
thread_label,
|
|
id: ev.effective_approval_id(),
|
|
command: ev.command.clone(),
|
|
reason: ev.reason.clone(),
|
|
available_decisions: ev.effective_available_decisions(),
|
|
network_approval_context: ev.network_approval_context.clone(),
|
|
additional_permissions: ev.additional_permissions.clone(),
|
|
}))
|
|
}
|
|
EventMsg::ApplyPatchApprovalRequest(ev) => Some(ThreadInteractiveRequest::Approval(
|
|
ApprovalRequest::ApplyPatch {
|
|
thread_id,
|
|
thread_label,
|
|
id: ev.call_id.clone(),
|
|
reason: ev.reason.clone(),
|
|
cwd: self
|
|
.thread_cwd(thread_id)
|
|
.await
|
|
.unwrap_or_else(|| self.config.cwd.clone()),
|
|
changes: ev.changes.clone(),
|
|
},
|
|
)),
|
|
EventMsg::ElicitationRequest(ev) => {
|
|
if let Some(request) =
|
|
McpServerElicitationFormRequest::from_event(thread_id, ev.clone())
|
|
{
|
|
Some(ThreadInteractiveRequest::McpServerElicitation(request))
|
|
} else {
|
|
Some(ThreadInteractiveRequest::Approval(
|
|
ApprovalRequest::McpElicitation {
|
|
thread_id,
|
|
thread_label,
|
|
server_name: ev.server_name.clone(),
|
|
request_id: ev.id.clone(),
|
|
message: ev.request.message().to_string(),
|
|
},
|
|
))
|
|
}
|
|
}
|
|
EventMsg::RequestPermissions(ev) => Some(ThreadInteractiveRequest::Approval(
|
|
ApprovalRequest::Permissions {
|
|
thread_id,
|
|
thread_label,
|
|
call_id: ev.call_id.clone(),
|
|
reason: ev.reason.clone(),
|
|
permissions: ev.permissions.clone(),
|
|
},
|
|
)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn submit_op_to_thread(&mut self, thread_id: ThreadId, op: Op) {
|
|
let replay_state_op =
|
|
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
|
|
let submitted = if self.active_thread_id == Some(thread_id) {
|
|
self.chat_widget.submit_op(op)
|
|
} else {
|
|
crate::session_log::log_outbound_op(&op);
|
|
match self.server.get_thread(thread_id).await {
|
|
Ok(thread) => match thread.submit(op).await {
|
|
Ok(_) => true,
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to submit op to thread {thread_id}: {err}"
|
|
));
|
|
false
|
|
}
|
|
},
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to find thread {thread_id} for approval response: {err}"
|
|
));
|
|
false
|
|
}
|
|
}
|
|
};
|
|
if submitted && let Some(op) = replay_state_op.as_ref() {
|
|
self.note_thread_outbound_op(thread_id, op).await;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
}
|
|
|
|
async fn refresh_pending_thread_approvals(&mut self) {
|
|
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
|
|
.thread_event_channels
|
|
.iter()
|
|
.map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store)))
|
|
.collect();
|
|
|
|
let mut pending_thread_ids = Vec::new();
|
|
for (thread_id, store) in channels {
|
|
if Some(thread_id) == self.active_thread_id {
|
|
continue;
|
|
}
|
|
|
|
let store = store.lock().await;
|
|
if store.has_pending_thread_approvals() {
|
|
pending_thread_ids.push(thread_id);
|
|
}
|
|
}
|
|
|
|
pending_thread_ids.sort_by_key(ThreadId::to_string);
|
|
|
|
let threads = pending_thread_ids
|
|
.into_iter()
|
|
.map(|thread_id| self.thread_label(thread_id))
|
|
.collect();
|
|
|
|
self.chat_widget.set_pending_thread_approvals(threads);
|
|
}
|
|
|
|
async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> {
|
|
let refresh_pending_thread_approvals =
|
|
ThreadEventStore::event_can_change_pending_thread_approvals(&event);
|
|
let inactive_interactive_request = if self.active_thread_id != Some(thread_id) {
|
|
self.interactive_request_for_thread_event(thread_id, &event)
|
|
.await
|
|
} else {
|
|
None
|
|
};
|
|
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");
|
|
}
|
|
}
|
|
} else if let Some(request) = inactive_interactive_request {
|
|
match request {
|
|
ThreadInteractiveRequest::Approval(request) => {
|
|
self.chat_widget.push_approval_request(request);
|
|
}
|
|
ThreadInteractiveRequest::McpServerElicitation(request) => {
|
|
self.chat_widget
|
|
.push_mcp_server_elicitation_request(request);
|
|
}
|
|
}
|
|
}
|
|
if refresh_pending_thread_approvals {
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_routed_thread_event(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
event: Event,
|
|
) -> Result<()> {
|
|
if !self.thread_event_channels.contains_key(&thread_id) {
|
|
tracing::debug!("dropping stale event for untracked thread {thread_id}");
|
|
return Ok(());
|
|
}
|
|
|
|
self.enqueue_thread_event(thread_id, event).await
|
|
}
|
|
|
|
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.upsert_agent_picker_thread(
|
|
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
|
|
/*is_closed*/ false,
|
|
);
|
|
self.ensure_thread_channel(thread_id);
|
|
self.activate_thread_channel(thread_id).await;
|
|
self.enqueue_thread_event(thread_id, event).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?;
|
|
}
|
|
} else {
|
|
self.pending_primary_events.push_back(event);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Opens the `/agent` picker after refreshing cached labels for known threads.
|
|
///
|
|
/// The picker state is derived from long-lived thread channels plus best-effort metadata
|
|
/// refreshes from the backend. Refresh failures are treated as "thread is only inspectable by
|
|
/// historical id now" and converted into closed picker entries instead of deleting them, so
|
|
/// the stable traversal order remains intact for review and keyboard navigation.
|
|
async fn open_agent_picker(&mut self) {
|
|
let thread_ids: Vec<ThreadId> = self.thread_event_channels.keys().cloned().collect();
|
|
for thread_id in thread_ids {
|
|
match self.server.get_thread(thread_id).await {
|
|
Ok(thread) => {
|
|
let session_source = thread.config_snapshot().await.session_source;
|
|
self.upsert_agent_picker_thread(
|
|
thread_id,
|
|
session_source.get_nickname(),
|
|
session_source.get_agent_role(),
|
|
/*is_closed*/ false,
|
|
);
|
|
}
|
|
Err(_) => {
|
|
self.mark_agent_picker_thread_closed(thread_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
let has_non_primary_agent_thread = self
|
|
.agent_navigation
|
|
.has_non_primary_thread(self.primary_thread_id);
|
|
if !self.config.features.enabled(Feature::Collab) && !has_non_primary_agent_thread {
|
|
self.chat_widget.open_multi_agent_enable_prompt();
|
|
return;
|
|
}
|
|
|
|
if self.agent_navigation.is_empty() {
|
|
self.chat_widget
|
|
.add_info_message("No agents available yet.".to_string(), /*hint*/ None);
|
|
return;
|
|
}
|
|
|
|
let mut initial_selected_idx = None;
|
|
let items: Vec<SelectionItem> = self
|
|
.agent_navigation
|
|
.ordered_threads()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, (thread_id, entry))| {
|
|
if self.active_thread_id == Some(*thread_id) {
|
|
initial_selected_idx = Some(idx);
|
|
}
|
|
let id = *thread_id;
|
|
let is_primary = self.primary_thread_id == Some(*thread_id);
|
|
let name = format_agent_picker_item_name(
|
|
entry.agent_nickname.as_deref(),
|
|
entry.agent_role.as_deref(),
|
|
is_primary,
|
|
);
|
|
let uuid = thread_id.to_string();
|
|
SelectionItem {
|
|
name: name.clone(),
|
|
name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed),
|
|
description: Some(uuid.clone()),
|
|
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(format!("{name} {uuid}")),
|
|
..Default::default()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
self.chat_widget.show_selection_view(SelectionViewParams {
|
|
title: Some("Subagents".to_string()),
|
|
subtitle: Some(AgentNavigationState::picker_subtitle()),
|
|
footer_hint: Some(standard_popup_hint_line()),
|
|
items,
|
|
initial_selected_idx,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
/// Updates cached picker metadata and then mirrors any visible-label change into the footer.
|
|
///
|
|
/// These two writes stay paired so the picker rows and contextual footer continue to describe
|
|
/// the same displayed thread after nickname or role updates.
|
|
fn upsert_agent_picker_thread(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
agent_nickname: Option<String>,
|
|
agent_role: Option<String>,
|
|
is_closed: bool,
|
|
) {
|
|
self.agent_navigation
|
|
.upsert(thread_id, agent_nickname, agent_role, is_closed);
|
|
self.sync_active_agent_label();
|
|
}
|
|
|
|
/// Marks a cached picker thread closed and recomputes the contextual footer label.
|
|
///
|
|
/// Closing a thread is not the same as removing it: users can still inspect finished agent
|
|
/// transcripts, and the stable next/previous traversal order should not collapse around them.
|
|
fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) {
|
|
self.agent_navigation.mark_closed(thread_id);
|
|
self.sync_active_agent_label();
|
|
}
|
|
|
|
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 live_thread = match self.server.get_thread(thread_id).await {
|
|
Ok(thread) => Some(thread),
|
|
Err(err) => {
|
|
if self.thread_event_channels.contains_key(&thread_id) {
|
|
self.mark_agent_picker_thread_closed(thread_id);
|
|
None
|
|
} else {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to attach to agent thread {thread_id}: {err}"
|
|
));
|
|
return Ok(());
|
|
}
|
|
}
|
|
};
|
|
let is_replay_only = live_thread.is_none();
|
|
|
|
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 = if let Some(thread) = live_thread {
|
|
crate::chatwidget::spawn_op_forwarder(thread)
|
|
} else {
|
|
let (tx, _rx) = unbounded_channel();
|
|
tx
|
|
};
|
|
self.replace_chat_widget(ChatWidget::new_with_op_sender(init, codex_op_tx));
|
|
|
|
self.reset_for_thread_switch(tui)?;
|
|
self.replay_thread_snapshot(snapshot, !is_replay_only);
|
|
if is_replay_only {
|
|
self.chat_widget.add_info_message(
|
|
format!("Agent thread {thread_id} is closed. Replaying saved transcript."),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
self.drain_active_thread_events(tui).await?;
|
|
self.refresh_pending_thread_approvals().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.abort_all_thread_event_listeners();
|
|
self.thread_event_channels.clear();
|
|
self.agent_navigation.clear();
|
|
self.active_thread_id = None;
|
|
self.active_thread_rx = None;
|
|
self.primary_thread_id = None;
|
|
self.pending_primary_events.clear();
|
|
self.chat_widget.set_pending_thread_approvals(Vec::new());
|
|
self.sync_active_agent_label();
|
|
}
|
|
|
|
fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
|
|
let previous_terminal_title = self.chat_widget.last_terminal_title.take();
|
|
if chat_widget.last_terminal_title.is_none() {
|
|
chat_widget.last_terminal_title = previous_terminal_title;
|
|
}
|
|
self.chat_widget = chat_widget;
|
|
self.sync_active_agent_label();
|
|
self.refresh_status_surfaces();
|
|
}
|
|
|
|
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
|
|
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
|
// history.
|
|
self.refresh_in_memory_config_from_disk_best_effort("starting a new thread")
|
|
.await;
|
|
let model = self.chat_widget.current_model().to_string();
|
|
let config = self.fresh_session_config();
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.thread_id(),
|
|
self.chat_widget.thread_name(),
|
|
);
|
|
self.shutdown_current_thread().await;
|
|
let report = self
|
|
.server
|
|
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
|
.await;
|
|
if !report.submit_failed.is_empty() || !report.timed_out.is_empty() {
|
|
tracing::warn!(
|
|
submit_failed = report.submit_failed.len(),
|
|
timed_out = report.timed_out.len(),
|
|
"failed to close all threads"
|
|
);
|
|
}
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config,
|
|
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,
|
|
feedback_audience: self.feedback_audience,
|
|
model: Some(model),
|
|
startup_tooltip_override: None,
|
|
status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(),
|
|
session_telemetry: self.session_telemetry.clone(),
|
|
terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(),
|
|
};
|
|
self.replace_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();
|
|
}
|
|
|
|
fn fresh_session_config(&self) -> Config {
|
|
let mut config = self.config.clone();
|
|
config.service_tier = self.chat_widget.current_service_tier();
|
|
config
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Returns `(closed_thread_id, primary_thread_id)` when a non-primary active
|
|
/// thread has died and we should fail over to the primary thread.
|
|
///
|
|
/// A user-requested shutdown (`ExitMode::ShutdownFirst`) sets
|
|
/// `pending_shutdown_exit_thread_id`; matching shutdown completions are ignored
|
|
/// here so Ctrl+C-like exits don't accidentally resurrect the main thread.
|
|
///
|
|
/// Failover is only eligible when all of these are true:
|
|
/// 1. the event is `ShutdownComplete`;
|
|
/// 2. the active thread differs from the primary thread;
|
|
/// 3. the active thread is not the pending shutdown-exit thread.
|
|
fn active_non_primary_shutdown_target(&self, msg: &EventMsg) -> Option<(ThreadId, ThreadId)> {
|
|
if !matches!(msg, EventMsg::ShutdownComplete) {
|
|
return None;
|
|
}
|
|
let active_thread_id = self.active_thread_id?;
|
|
let primary_thread_id = self.primary_thread_id?;
|
|
if self.pending_shutdown_exit_thread_id == Some(active_thread_id) {
|
|
return None;
|
|
}
|
|
(active_thread_id != primary_thread_id).then_some((active_thread_id, primary_thread_id))
|
|
}
|
|
|
|
fn replay_thread_snapshot(
|
|
&mut self,
|
|
snapshot: ThreadEventSnapshot,
|
|
resume_restored_queue: bool,
|
|
) {
|
|
if let Some(event) = snapshot.session_configured {
|
|
self.handle_codex_event_replay(event);
|
|
}
|
|
self.chat_widget
|
|
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
|
self.chat_widget
|
|
.restore_thread_input_state(snapshot.input_state);
|
|
for event in snapshot.events {
|
|
self.handle_codex_event_replay(event);
|
|
}
|
|
self.chat_widget
|
|
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
|
if resume_restored_queue {
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
}
|
|
self.refresh_status_surfaces();
|
|
}
|
|
|
|
fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool {
|
|
matches!(
|
|
session_selection,
|
|
SessionSelection::StartFresh | SessionSelection::Exit
|
|
)
|
|
}
|
|
|
|
fn should_handle_active_thread_events(
|
|
waiting_for_initial_session_configured: bool,
|
|
has_active_thread_receiver: bool,
|
|
) -> bool {
|
|
has_active_thread_receiver && !waiting_for_initial_session_configured
|
|
}
|
|
|
|
fn should_stop_waiting_for_initial_session(
|
|
waiting_for_initial_session_configured: bool,
|
|
primary_thread_id: Option<ThreadId>,
|
|
) -> bool {
|
|
waiting_for_initial_session_configured && primary_thread_id.is_some()
|
|
}
|
|
|
|
#[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,
|
|
should_prompt_windows_sandbox_nux_at_startup: 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);
|
|
emit_project_config_warnings(&app_event_tx, &config);
|
|
emit_missing_system_bwrap_warning(&app_event_tx);
|
|
emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await;
|
|
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,
|
|
auth_manager.clone(),
|
|
SessionSource::Cli,
|
|
CollaborationModesConfig {
|
|
default_mode_request_user_input: config
|
|
.features
|
|
.enabled(Feature::DefaultModeRequestUserInput),
|
|
},
|
|
));
|
|
// TODO(xl): Move into PluginManager once this no longer depends on config feature gating.
|
|
thread_manager
|
|
.plugins_manager()
|
|
.maybe_start_curated_repo_sync_for_config(&config, auth_manager.clone());
|
|
let mut model = thread_manager
|
|
.get_models_manager()
|
|
.get_default_model(&config.model, RefreshStrategy::Offline)
|
|
.await;
|
|
let available_models = thread_manager
|
|
.get_models_manager()
|
|
.list_models(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();
|
|
// Determine who should see internal Slack routing. We treat
|
|
// `@openai.com` emails as employees and default to `External` when the
|
|
// email is unavailable (for example, API key auth).
|
|
let feedback_audience = if auth_ref
|
|
.and_then(CodexAuth::get_account_email)
|
|
.is_some_and(|email| email.ends_with("@openai.com"))
|
|
{
|
|
FeedbackAudience::OpenAiEmployee
|
|
} else {
|
|
FeedbackAudience::External
|
|
};
|
|
let auth_mode = auth_ref
|
|
.map(CodexAuth::auth_mode)
|
|
.map(TelemetryAuthMode::from);
|
|
let session_telemetry = SessionTelemetry::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_mode,
|
|
codex_core::default_client::originator().value,
|
|
config.otel.log_user_prompt,
|
|
user_agent(),
|
|
SessionSource::Cli,
|
|
);
|
|
if config
|
|
.tui_status_line
|
|
.as_ref()
|
|
.is_some_and(|cmd| !cmd.is_empty())
|
|
{
|
|
session_telemetry.counter("codex.status_line", /*inc*/ 1, &[]);
|
|
}
|
|
|
|
let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false));
|
|
let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false));
|
|
|
|
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
|
let wait_for_initial_session_configured =
|
|
Self::should_wait_for_initial_session(&session_selection);
|
|
let mut chat_widget = match session_selection {
|
|
SessionSelection::StartFresh | SessionSelection::Exit => {
|
|
let startup_tooltip_override =
|
|
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run)
|
|
.await;
|
|
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,
|
|
feedback_audience,
|
|
model: Some(model.clone()),
|
|
startup_tooltip_override,
|
|
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
|
session_telemetry: session_telemetry.clone(),
|
|
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
|
|
.clone(),
|
|
};
|
|
ChatWidget::new(init, thread_manager.clone())
|
|
}
|
|
SessionSelection::Resume(target_session) => {
|
|
let resumed = thread_manager
|
|
.resume_thread_from_rollout(
|
|
config.clone(),
|
|
target_session.path.clone(),
|
|
auth_manager.clone(),
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.wrap_err_with(|| {
|
|
let path_display = target_session.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,
|
|
feedback_audience,
|
|
model: config.model.clone(),
|
|
startup_tooltip_override: None,
|
|
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
|
session_telemetry: session_telemetry.clone(),
|
|
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
|
|
.clone(),
|
|
};
|
|
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured)
|
|
}
|
|
SessionSelection::Fork(target_session) => {
|
|
session_telemetry.counter(
|
|
"codex.thread.fork",
|
|
/*inc*/ 1,
|
|
&[("source", "cli_subcommand")],
|
|
);
|
|
let forked = thread_manager
|
|
.fork_thread(
|
|
usize::MAX,
|
|
config.clone(),
|
|
target_session.path.clone(),
|
|
/*persist_extended_history*/ false,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
.wrap_err_with(|| {
|
|
let path_display = target_session.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,
|
|
feedback_audience,
|
|
model: config.model.clone(),
|
|
startup_tooltip_override: None,
|
|
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
|
session_telemetry: session_telemetry.clone(),
|
|
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned
|
|
.clone(),
|
|
};
|
|
ChatWidget::new_from_existing(init, forked.thread, forked.session_configured)
|
|
}
|
|
};
|
|
|
|
chat_widget
|
|
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
|
|
|
|
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(),
|
|
session_telemetry: session_telemetry.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)),
|
|
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
|
|
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
|
|
backtrack: BacktrackState::default(),
|
|
backtrack_render_pending: false,
|
|
feedback: feedback.clone(),
|
|
feedback_audience,
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
pending_shutdown_exit_thread_id: None,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
thread_event_listener_tasks: HashMap::new(),
|
|
agent_navigation: AgentNavigationState::default(),
|
|
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.permissions.sandbox_policy.get(),
|
|
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
|
| codex_protocol::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.permissions.sandbox_policy.get().clone();
|
|
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx);
|
|
}
|
|
}
|
|
|
|
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 mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
|
|
|
|
#[cfg(not(debug_assertions))]
|
|
let pre_loop_exit_reason = 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?;
|
|
match control {
|
|
AppRunControl::Continue => None,
|
|
AppRunControl::Exit(exit_reason) => Some(exit_reason),
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
#[cfg(debug_assertions)]
|
|
let pre_loop_exit_reason: Option<ExitReason> = None;
|
|
|
|
let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason {
|
|
Ok(exit_reason)
|
|
} else {
|
|
loop {
|
|
let control = select! {
|
|
Some(event) = app_event_rx.recv() => {
|
|
match app.handle_event(tui, event).await {
|
|
Ok(control) => control,
|
|
Err(err) => break Err(err),
|
|
}
|
|
}
|
|
active = async {
|
|
if let Some(rx) = app.active_thread_rx.as_mut() {
|
|
rx.recv().await
|
|
} else {
|
|
None
|
|
}
|
|
}, if App::should_handle_active_thread_events(
|
|
waiting_for_initial_session_configured,
|
|
app.active_thread_rx.is_some()
|
|
) => {
|
|
if let Some(event) = active {
|
|
if let Err(err) = app.handle_active_thread_event(tui, event).await {
|
|
break Err(err);
|
|
}
|
|
} else {
|
|
app.clear_active_thread().await;
|
|
}
|
|
AppRunControl::Continue
|
|
}
|
|
Some(event) = tui_events.next() => {
|
|
match app.handle_tui_event(tui, event).await {
|
|
Ok(control) => control,
|
|
Err(err) => break Err(err),
|
|
}
|
|
}
|
|
// Listen on new thread creation due to collab tools.
|
|
created = thread_created_rx.recv(), if listen_for_threads => {
|
|
match created {
|
|
Ok(thread_id) => {
|
|
if let Err(err) = app.handle_thread_created(thread_id).await {
|
|
break Err(err);
|
|
}
|
|
}
|
|
Err(broadcast::error::RecvError::Lagged(_)) => {
|
|
tracing::warn!("thread_created receiver lagged; skipping resync");
|
|
}
|
|
Err(broadcast::error::RecvError::Closed) => {
|
|
listen_for_threads = false;
|
|
}
|
|
}
|
|
AppRunControl::Continue
|
|
}
|
|
};
|
|
if App::should_stop_waiting_for_initial_session(
|
|
waiting_for_initial_session_configured,
|
|
app.primary_thread_id,
|
|
) {
|
|
waiting_for_initial_session_configured = false;
|
|
}
|
|
match control {
|
|
AppRunControl::Continue => {}
|
|
AppRunControl::Exit(reason) => break Ok(reason),
|
|
}
|
|
}
|
|
};
|
|
let clear_result = tui.terminal.clear();
|
|
let exit_reason = match exit_reason_result {
|
|
Ok(exit_reason) => {
|
|
clear_result?;
|
|
exit_reason
|
|
}
|
|
Err(err) => {
|
|
if let Err(clear_err) = clear_result {
|
|
tracing::warn!(error = %clear_err, "failed to clear terminal UI");
|
|
}
|
|
return Err(err);
|
|
}
|
|
};
|
|
Ok(AppExitInfo {
|
|
token_usage: app.token_usage(),
|
|
thread_id: app.chat_widget.thread_id(),
|
|
thread_name: app.chat_widget.thread_name(),
|
|
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 matches!(event, TuiEvent::Draw) {
|
|
let size = tui.terminal.size()?;
|
|
if size != tui.terminal.last_known_screen_size {
|
|
self.refresh_status_surfaces();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
// Allow widgets to process any pending timers before rendering.
|
|
self.chat_widget.pre_draw_tick();
|
|
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 => {
|
|
self.start_fresh_session_with_summary_hint(tui).await;
|
|
}
|
|
AppEvent::ClearUi => {
|
|
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
|
self.reset_app_ui_state_after_clear();
|
|
|
|
self.start_fresh_session_with_summary_hint(tui).await;
|
|
}
|
|
AppEvent::OpenResumePicker => {
|
|
match crate::resume_picker::run_resume_picker(
|
|
tui,
|
|
&self.config,
|
|
/*show_all*/ false,
|
|
)
|
|
.await?
|
|
{
|
|
SessionSelection::Resume(target_session) => {
|
|
let current_cwd = self.config.cwd.clone();
|
|
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
|
|
tui,
|
|
&self.config,
|
|
¤t_cwd,
|
|
target_session.thread_id,
|
|
&target_session.path,
|
|
CwdPromptAction::Resume,
|
|
/*allow_prompt*/ true,
|
|
)
|
|
.await?
|
|
{
|
|
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
|
|
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
|
|
crate::ResolveCwdOutcome::Exit => {
|
|
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
|
}
|
|
};
|
|
let mut resume_config = match self
|
|
.rebuild_config_for_resume_or_fallback(¤t_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);
|
|
}
|
|
};
|
|
self.apply_runtime_policy_overrides(&mut resume_config);
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.thread_id(),
|
|
self.chat_widget.thread_name(),
|
|
);
|
|
match self
|
|
.server
|
|
.resume_thread_from_rollout(
|
|
resume_config.clone(),
|
|
target_session.path.clone(),
|
|
self.auth_manager.clone(),
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(resumed) => {
|
|
self.shutdown_current_thread().await;
|
|
self.config = resume_config;
|
|
tui.set_notification_method(self.config.tui_notification_method);
|
|
self.file_search.update_search_dir(self.config.cwd.clone());
|
|
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
|
tui,
|
|
self.config.clone(),
|
|
);
|
|
self.replace_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 = target_session.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 => {
|
|
self.session_telemetry.counter(
|
|
"codex.thread.fork",
|
|
/*inc*/ 1,
|
|
&[("source", "slash_command")],
|
|
);
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.thread_id(),
|
|
self.chat_widget.thread_name(),
|
|
);
|
|
self.chat_widget
|
|
.add_plain_history_lines(vec!["/fork".magenta().into()]);
|
|
if let Some(path) = self.chat_widget.rollout_path() {
|
|
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
|
|
.await;
|
|
// Fresh threads expose a precomputed path, but the file is
|
|
// materialized lazily on first user message.
|
|
if path.exists() {
|
|
match self
|
|
.server
|
|
.fork_thread(
|
|
usize::MAX,
|
|
self.config.clone(),
|
|
path.clone(),
|
|
/*persist_extended_history*/ false,
|
|
/*parent_trace*/ None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(forked) => {
|
|
self.shutdown_current_thread().await;
|
|
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
|
tui,
|
|
self.config.clone(),
|
|
);
|
|
self.replace_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(
|
|
"A thread must contain at least one turn before it can be forked."
|
|
.to_string(),
|
|
);
|
|
}
|
|
} else {
|
|
self.chat_widget.add_error_message(
|
|
"A thread must contain at least one turn before it can be forked."
|
|
.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::ApplyThreadRollback { num_turns } => {
|
|
if self.apply_non_pending_thread_rollback(num_turns) {
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
}
|
|
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(COMMIT_ANIMATION_TICK);
|
|
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::ThreadEvent { thread_id, event } => {
|
|
self.handle_routed_thread_event(thread_id, event).await?;
|
|
}
|
|
AppEvent::Exit(mode) => {
|
|
return Ok(self.handle_exit_mode(mode));
|
|
}
|
|
AppEvent::FatalExitRequest(message) => {
|
|
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
|
|
}
|
|
AppEvent::CodexOp(op) => {
|
|
let replay_state_op =
|
|
ThreadEventStore::op_can_change_pending_replay_state(&op).then(|| op.clone());
|
|
let submitted = self.chat_widget.submit_op(op);
|
|
if submitted && let Some(op) = replay_state_op.as_ref() {
|
|
self.note_active_thread_outbound_op(op).await;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
}
|
|
AppEvent::SubmitThreadOp { thread_id, op } => {
|
|
self.submit_op_to_thread(thread_id, op).await;
|
|
}
|
|
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::OpenAppLink {
|
|
app_id,
|
|
title,
|
|
description,
|
|
instructions,
|
|
url,
|
|
is_installed,
|
|
is_enabled,
|
|
} => {
|
|
self.chat_widget
|
|
.open_app_link_view(crate::bottom_pane::AppLinkViewParams {
|
|
app_id,
|
|
title,
|
|
description,
|
|
instructions,
|
|
url,
|
|
is_installed,
|
|
is_enabled,
|
|
suggest_reason: None,
|
|
suggestion_type: None,
|
|
elicitation_target: None,
|
|
});
|
|
}
|
|
AppEvent::OpenUrlInBrowser { url } => {
|
|
self.open_url_in_browser(url);
|
|
}
|
|
AppEvent::RefreshConnectors { force_refetch } => {
|
|
self.chat_widget.refresh_connectors(force_refetch);
|
|
}
|
|
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::ConnectorsLoaded { result, is_final } => {
|
|
self.chat_widget.on_connectors_loaded(result, is_final);
|
|
}
|
|
AppEvent::UpdateReasoningEffort(effort) => {
|
|
self.on_update_reasoning_effort(effort);
|
|
self.refresh_status_surfaces();
|
|
}
|
|
AppEvent::UpdateModel(model) => {
|
|
self.chat_widget.set_model(&model);
|
|
self.refresh_status_surfaces();
|
|
}
|
|
AppEvent::UpdateCollaborationMode(mask) => {
|
|
self.chat_widget.set_collaboration_mask(mask);
|
|
self.refresh_status_surfaces();
|
|
}
|
|
AppEvent::UpdatePersonality(personality) => {
|
|
self.on_update_personality(personality);
|
|
}
|
|
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
|
|
self.chat_widget.open_realtime_audio_device_selection(kind);
|
|
}
|
|
AppEvent::OpenReasoningPopup { model } => {
|
|
self.chat_widget.open_reasoning_popup(model);
|
|
}
|
|
AppEvent::OpenPlanReasoningScopePrompt { model, effort } => {
|
|
self.chat_widget
|
|
.open_plan_reasoning_scope_prompt(model, effort);
|
|
}
|
|
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 } => {
|
|
self.session_telemetry.counter(
|
|
"codex.windows_sandbox.fallback_prompt_shown",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
self.chat_widget.clear_windows_sandbox_setup_status();
|
|
if let Some(started_at) = self.windows_sandbox.setup_started_at.take() {
|
|
self.session_telemetry.record_duration(
|
|
"codex.windows_sandbox.elevated_setup_duration_ms",
|
|
started_at.elapsed(),
|
|
&[("result", "failure")],
|
|
);
|
|
}
|
|
self.chat_widget
|
|
.open_windows_sandbox_fallback_prompt(preset);
|
|
}
|
|
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 session_telemetry = self.session_telemetry.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(()) => {
|
|
session_telemetry.counter(
|
|
"codex.windows_sandbox.elevated_setup_success",
|
|
/*inc*/ 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));
|
|
}
|
|
session_telemetry.counter(
|
|
codex_core::windows_sandbox::elevated_setup_failure_metric_name(
|
|
&err,
|
|
),
|
|
/*inc*/ 1,
|
|
&tags,
|
|
);
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to run elevated Windows sandbox setup"
|
|
);
|
|
AppEvent::OpenWindowsSandboxFallbackPrompt { preset }
|
|
}
|
|
};
|
|
tx.send(event);
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = preset;
|
|
}
|
|
}
|
|
AppEvent::BeginWindowsSandboxLegacySetup { 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();
|
|
let session_telemetry = self.session_telemetry.clone();
|
|
|
|
self.chat_widget.show_windows_sandbox_setup_status();
|
|
tokio::task::spawn_blocking(move || {
|
|
if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight(
|
|
&policy,
|
|
policy_cwd.as_path(),
|
|
command_cwd.as_path(),
|
|
&env_map,
|
|
codex_home.as_path(),
|
|
) {
|
|
session_telemetry.counter(
|
|
"codex.windows_sandbox.legacy_setup_preflight_failed",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
tracing::warn!(
|
|
error = %err,
|
|
"failed to preflight non-admin Windows sandbox setup"
|
|
);
|
|
}
|
|
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
|
preset,
|
|
mode: WindowsSandboxEnableMode::Legacy,
|
|
});
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = preset;
|
|
}
|
|
}
|
|
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_info_event(
|
|
format!("Granting sandbox read access to {path} ..."),
|
|
/*hint*/ None,
|
|
));
|
|
|
|
let policy = self.config.permissions.sandbox_policy.get().clone();
|
|
let policy_cwd = self.config.cwd.clone();
|
|
let command_cwd = self.config.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();
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let requested_path = PathBuf::from(path);
|
|
let event = match codex_core::windows_sandbox_read_grants::grant_read_root_non_elevated(
|
|
&policy,
|
|
policy_cwd.as_path(),
|
|
command_cwd.as_path(),
|
|
&env_map,
|
|
codex_home.as_path(),
|
|
requested_path.as_path(),
|
|
) {
|
|
Ok(canonical_path) => AppEvent::WindowsSandboxGrantReadRootCompleted {
|
|
path: canonical_path,
|
|
error: None,
|
|
},
|
|
Err(err) => AppEvent::WindowsSandboxGrantReadRootCompleted {
|
|
path: requested_path,
|
|
error: Some(err.to_string()),
|
|
},
|
|
};
|
|
tx.send(event);
|
|
});
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = path;
|
|
}
|
|
}
|
|
AppEvent::WindowsSandboxGrantReadRootCompleted { path, error } => match error {
|
|
Some(err) => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_error_event(format!("Error: {err}")));
|
|
}
|
|
None => {
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_info_event(
|
|
format!("Sandbox read access granted for {}", path.display()),
|
|
/*hint*/ None,
|
|
));
|
|
}
|
|
},
|
|
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.session_telemetry.record_duration(
|
|
"codex.windows_sandbox.elevated_setup_duration_ms",
|
|
started_at.elapsed(),
|
|
&[("result", "success")],
|
|
);
|
|
}
|
|
let profile = self.active_profile.as_deref();
|
|
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
|
|
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_windows_sandbox_mode(if elevated_enabled {
|
|
"elevated"
|
|
} else {
|
|
"unelevated"
|
|
})
|
|
.clear_legacy_windows_sandbox_keys();
|
|
match builder.apply().await {
|
|
Ok(()) => {
|
|
if elevated_enabled {
|
|
self.config.set_windows_sandbox_enabled(/*value*/ false);
|
|
self.config
|
|
.set_windows_elevated_sandbox_enabled(/*value*/ true);
|
|
} else {
|
|
self.config.set_windows_sandbox_enabled(/*value*/ true);
|
|
self.config
|
|
.set_windows_elevated_sandbox_enabled(/*value*/ false);
|
|
}
|
|
self.chat_widget.set_windows_sandbox_mode(
|
|
self.config.permissions.windows_sandbox_mode,
|
|
);
|
|
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,
|
|
approvals_reviewer: None,
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: Some(windows_sandbox_level),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: 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),
|
|
approvals_reviewer: Some(self.config.approvals_reviewer),
|
|
sandbox_policy: Some(preset.sandbox.clone()),
|
|
windows_sandbox_level: Some(windows_sandbox_level),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: 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()));
|
|
let _ = mode;
|
|
self.chat_widget.add_plain_history_lines(vec![
|
|
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
|
|
Line::from(vec![
|
|
" ".into(),
|
|
"Codex can now safely edit files and execute commands in your computer"
|
|
.dark_gray(),
|
|
]),
|
|
]);
|
|
}
|
|
}
|
|
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 effort_label = effort
|
|
.map(|selected_effort| selected_effort.to_string())
|
|
.unwrap_or_else(|| "default".to_string());
|
|
tracing::info!("Selected model: {model}, Selected effort: {effort_label}");
|
|
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, /*hint*/ 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_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, /*hint*/ 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::PersistServiceTierSelection { service_tier } => {
|
|
self.refresh_status_surfaces();
|
|
let profile = self.active_profile.as_deref();
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_service_tier(service_tier)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let status = if service_tier.is_some() { "on" } else { "off" };
|
|
let mut message = format!("Fast mode set to {status}");
|
|
if let Some(profile) = profile {
|
|
message.push_str(" for ");
|
|
message.push_str(profile);
|
|
message.push_str(" profile");
|
|
}
|
|
self.chat_widget.add_info_message(message, /*hint*/ None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to persist fast mode selection");
|
|
if let Some(profile) = profile {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Fast mode for profile `{profile}`: {err}"
|
|
));
|
|
} else {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save default Fast mode: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
|
|
let builder = match kind {
|
|
RealtimeAudioDeviceKind::Microphone => {
|
|
ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_realtime_microphone(name.as_deref())
|
|
}
|
|
RealtimeAudioDeviceKind::Speaker => {
|
|
ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_realtime_speaker(name.as_deref())
|
|
}
|
|
};
|
|
|
|
match builder.apply().await {
|
|
Ok(()) => {
|
|
match kind {
|
|
RealtimeAudioDeviceKind::Microphone => {
|
|
self.config.realtime_audio.microphone = name.clone();
|
|
}
|
|
RealtimeAudioDeviceKind::Speaker => {
|
|
self.config.realtime_audio.speaker = name.clone();
|
|
}
|
|
}
|
|
self.chat_widget
|
|
.set_realtime_audio_device(kind, name.clone());
|
|
|
|
if self.chat_widget.realtime_conversation_is_live() {
|
|
self.chat_widget.open_realtime_audio_restart_prompt(kind);
|
|
} else {
|
|
let selection = name.unwrap_or_else(|| "System default".to_string());
|
|
self.chat_widget.add_info_message(
|
|
format!("Realtime {} set to {selection}", kind.noun()),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist realtime audio selection"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save realtime {}: {err}",
|
|
kind.noun()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::RestartRealtimeAudioDevice { kind } => {
|
|
self.chat_widget.restart_realtime_audio_device(kind);
|
|
}
|
|
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
|
let mut config = self.config.clone();
|
|
if !self.try_set_approval_policy_on_config(
|
|
&mut config,
|
|
policy,
|
|
"Failed to set approval policy",
|
|
"failed to set approval policy on app config",
|
|
) {
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.config = config;
|
|
self.runtime_approval_policy_override =
|
|
Some(self.config.permissions.approval_policy.value());
|
|
self.chat_widget
|
|
.set_approval_policy(self.config.permissions.approval_policy.value());
|
|
}
|
|
AppEvent::UpdateSandboxPolicy(policy) => {
|
|
#[cfg(target_os = "windows")]
|
|
let policy_is_workspace_write_or_ro = matches!(
|
|
&policy,
|
|
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
|
| codex_protocol::protocol::SandboxPolicy::ReadOnly { .. }
|
|
);
|
|
let policy_for_chat = policy.clone();
|
|
|
|
let mut config = self.config.clone();
|
|
if !self.try_set_sandbox_policy_on_config(
|
|
&mut config,
|
|
policy,
|
|
"Failed to set sandbox policy",
|
|
"failed to set sandbox policy on app config",
|
|
) {
|
|
return Ok(AppRunControl::Continue);
|
|
}
|
|
self.config = config;
|
|
if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) {
|
|
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.permissions.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.permissions.sandbox_policy.get().clone();
|
|
Self::spawn_world_writable_scan(
|
|
cwd,
|
|
env_map,
|
|
logs_base_dir,
|
|
sandbox_policy,
|
|
tx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::UpdateApprovalsReviewer(policy) => {
|
|
self.config.approvals_reviewer = policy;
|
|
self.chat_widget.set_approvals_reviewer(policy);
|
|
let profile = self.active_profile.as_deref();
|
|
let segments = if let Some(profile) = profile {
|
|
vec![
|
|
"profiles".to_string(),
|
|
profile.to_string(),
|
|
"approvals_reviewer".to_string(),
|
|
]
|
|
} else {
|
|
vec!["approvals_reviewer".to_string()]
|
|
};
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.with_edits([ConfigEdit::SetPath {
|
|
segments,
|
|
value: policy.to_string().into(),
|
|
}])
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist approvals reviewer update"
|
|
);
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save approvals reviewer: {err}"));
|
|
}
|
|
}
|
|
AppEvent::UpdateFeatureFlags { updates } => {
|
|
self.update_feature_flags(updates).await;
|
|
}
|
|
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::UpdatePlanModeReasoningEffort(effort) => {
|
|
self.config.plan_mode_reasoning_effort = effort;
|
|
self.chat_widget.set_plan_mode_reasoning_effort(effort);
|
|
self.refresh_status_surfaces();
|
|
}
|
|
AppEvent::PersistFullAccessWarningAcknowledged => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_full_access_warning(/*acknowledged*/ 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(/*acknowledged*/ 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(/*acknowledged*/ 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::PersistPlanModeReasoningEffort(effort) => {
|
|
let profile = self.active_profile.as_deref();
|
|
let segments = if let Some(profile) = profile {
|
|
vec![
|
|
"profiles".to_string(),
|
|
profile.to_string(),
|
|
"plan_mode_reasoning_effort".to_string(),
|
|
]
|
|
} else {
|
|
vec!["plan_mode_reasoning_effort".to_string()]
|
|
};
|
|
let edit = if let Some(effort) = effort {
|
|
ConfigEdit::SetPath {
|
|
segments,
|
|
value: effort.to_string().into(),
|
|
}
|
|
} else {
|
|
ConfigEdit::ClearPath { segments }
|
|
};
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist plan mode reasoning effort"
|
|
);
|
|
if let Some(profile) = profile {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Plan mode reasoning effort for profile `{profile}`: {err}"
|
|
));
|
|
} else {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Plan mode reasoning effort: {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().await;
|
|
}
|
|
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);
|
|
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
|
tracing::warn!(
|
|
error = %err,
|
|
"failed to refresh config after skill toggle"
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let path_display = path.display();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update skill config for {path_display}: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SetAppEnabled { id, enabled } => {
|
|
let edits = if enabled {
|
|
vec![
|
|
ConfigEdit::ClearPath {
|
|
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
|
|
},
|
|
ConfigEdit::ClearPath {
|
|
segments: vec![
|
|
"apps".to_string(),
|
|
id.clone(),
|
|
"disabled_reason".to_string(),
|
|
],
|
|
},
|
|
]
|
|
} else {
|
|
vec![
|
|
ConfigEdit::SetPath {
|
|
segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()],
|
|
value: false.into(),
|
|
},
|
|
ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"apps".to_string(),
|
|
id.clone(),
|
|
"disabled_reason".to_string(),
|
|
],
|
|
value: "user".into(),
|
|
},
|
|
]
|
|
};
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits(edits)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.chat_widget.update_connector_enabled(&id, enabled);
|
|
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
|
tracing::warn!(error = %err, "failed to refresh config after app toggle");
|
|
}
|
|
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to update app config for {id}: {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::Permissions {
|
|
permissions,
|
|
reason,
|
|
..
|
|
} => {
|
|
let _ = tui.enter_alt_screen();
|
|
let mut lines = Vec::new();
|
|
if let Some(reason) = reason {
|
|
lines.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
|
|
lines.push(Line::from(""));
|
|
}
|
|
if let Some(rule_line) =
|
|
crate::bottom_pane::format_requested_permissions_rule(&permissions)
|
|
{
|
|
lines.push(Line::from(vec![
|
|
"Permission rule: ".into(),
|
|
rule_line.cyan(),
|
|
]));
|
|
}
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))],
|
|
"P E R M I S S I O N S".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(),
|
|
));
|
|
}
|
|
},
|
|
#[cfg(not(target_os = "linux"))]
|
|
AppEvent::TranscriptionComplete { id, text } => {
|
|
self.chat_widget.replace_transcription(&id, &text);
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
AppEvent::TranscriptionFailed { id, error: _ } => {
|
|
self.chat_widget.remove_transcription_placeholder(&id);
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
AppEvent::UpdateRecordingMeter { id, text } => {
|
|
// Update in place to preserve the element id for subsequent frames.
|
|
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
|
|
if updated {
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
}
|
|
AppEvent::StatusLineSetup { items } => {
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
let edit = codex_core::config::edit::status_line_items_edit(&ids);
|
|
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await;
|
|
match apply_result {
|
|
Ok(()) => {
|
|
self.config.tui_status_line = Some(ids.clone());
|
|
self.chat_widget.setup_status_line(items);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to persist status line items; keeping previous selection");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save status line items: {err}"));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::StatusLineBranchUpdated { cwd, branch } => {
|
|
self.chat_widget.set_status_line_branch(cwd, branch);
|
|
self.refresh_status_surfaces();
|
|
}
|
|
AppEvent::StatusLineSetupCancelled => {
|
|
self.chat_widget.cancel_status_line_setup();
|
|
}
|
|
AppEvent::TerminalTitleSetup { items } => {
|
|
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
|
let edit = codex_core::config::edit::terminal_title_items_edit(&ids);
|
|
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await;
|
|
match apply_result {
|
|
Ok(()) => {
|
|
self.config.tui_terminal_title = Some(ids.clone());
|
|
self.chat_widget.setup_terminal_title(items);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(error = %err, "failed to persist terminal title items; keeping previous selection");
|
|
self.chat_widget.revert_terminal_title_setup_preview();
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save terminal title items: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
AppEvent::TerminalTitleSetupPreview { items } => {
|
|
self.chat_widget.preview_terminal_title(items);
|
|
}
|
|
AppEvent::TerminalTitleSetupCancelled => {
|
|
self.chat_widget.cancel_terminal_title_setup();
|
|
}
|
|
AppEvent::SyntaxThemeSelected { name } => {
|
|
let edit = codex_core::config::edit::syntax_theme_edit(&name);
|
|
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits([edit])
|
|
.apply()
|
|
.await;
|
|
match apply_result {
|
|
Ok(()) => {
|
|
// Ensure the selected theme is active in the current
|
|
// session. The preview callback covers arrow-key
|
|
// navigation, but if the user presses Enter without
|
|
// navigating, the runtime theme must still be applied.
|
|
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
|
|
&name,
|
|
Some(&self.config.codex_home),
|
|
) {
|
|
crate::render::highlight::set_syntax_theme(theme);
|
|
}
|
|
self.sync_tui_theme_selection(name);
|
|
}
|
|
Err(err) => {
|
|
self.restore_runtime_theme_from_config();
|
|
tracing::error!(error = %err, "failed to persist theme selection");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save theme: {err}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(AppRunControl::Continue)
|
|
}
|
|
|
|
fn handle_exit_mode(&mut self, mode: ExitMode) -> AppRunControl {
|
|
match mode {
|
|
ExitMode::ShutdownFirst => {
|
|
// Mark the thread we are explicitly shutting down for exit so
|
|
// its shutdown completion does not trigger agent failover.
|
|
self.pending_shutdown_exit_thread_id =
|
|
self.active_thread_id.or(self.chat_widget.thread_id());
|
|
if self.chat_widget.submit_op(Op::Shutdown) {
|
|
AppRunControl::Continue
|
|
} else {
|
|
self.pending_shutdown_exit_thread_id = None;
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
}
|
|
}
|
|
ExitMode::Immediate => {
|
|
self.pending_shutdown_exit_thread_id = None;
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_codex_event_now(&mut self, event: Event) {
|
|
let needs_refresh = matches!(
|
|
event.msg,
|
|
EventMsg::SessionConfigured(_) | EventMsg::TurnStarted(_) | EventMsg::TokenCount(_)
|
|
);
|
|
// This guard is only for intentional thread-switch shutdowns.
|
|
// App-exit shutdowns are tracked by `pending_shutdown_exit_thread_id`
|
|
// and resolved in `handle_active_thread_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);
|
|
|
|
if needs_refresh {
|
|
self.refresh_status_surfaces();
|
|
}
|
|
}
|
|
|
|
fn handle_codex_event_replay(&mut self, event: Event) {
|
|
self.chat_widget.handle_codex_event_replay(event);
|
|
}
|
|
|
|
/// Handles an event emitted by the currently active thread.
|
|
///
|
|
/// This function enforces shutdown intent routing: unexpected non-primary
|
|
/// thread shutdowns fail over to the primary thread, while user-requested
|
|
/// app exits consume only the tracked shutdown completion and then proceed.
|
|
async fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> {
|
|
// Capture this before any potential thread switch: we only want to clear
|
|
// the exit marker when the currently active thread acknowledges shutdown.
|
|
let pending_shutdown_exit_completed = matches!(&event.msg, EventMsg::ShutdownComplete)
|
|
&& self.pending_shutdown_exit_thread_id == self.active_thread_id;
|
|
|
|
// Processing order matters:
|
|
//
|
|
// 1. handle unexpected non-primary shutdown failover first;
|
|
// 2. clear pending exit marker for matching shutdown;
|
|
// 3. forward the event through normal handling.
|
|
//
|
|
// This preserves the mental model that user-requested exits do not trigger
|
|
// failover, while true sub-agent deaths still do.
|
|
if let Some((closed_thread_id, primary_thread_id)) =
|
|
self.active_non_primary_shutdown_target(&event.msg)
|
|
{
|
|
self.mark_agent_picker_thread_closed(closed_thread_id);
|
|
self.select_agent_thread(tui, primary_thread_id).await?;
|
|
if self.active_thread_id == Some(primary_thread_id) {
|
|
self.chat_widget.add_info_message(
|
|
format!(
|
|
"Agent thread {closed_thread_id} closed. Switched back to main thread."
|
|
),
|
|
/*hint*/ None,
|
|
);
|
|
} else {
|
|
self.clear_active_thread().await;
|
|
self.chat_widget.add_error_message(format!(
|
|
"Agent thread {closed_thread_id} closed. Failed to switch back to main thread {primary_thread_id}.",
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if pending_shutdown_exit_completed {
|
|
// Clear only after seeing the shutdown completion for the tracked
|
|
// thread, so unrelated shutdowns cannot consume this marker.
|
|
self.pending_shutdown_exit_thread_id = None;
|
|
}
|
|
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;
|
|
self.upsert_agent_picker_thread(
|
|
thread_id,
|
|
config_snapshot.session_source.get_nickname(),
|
|
config_snapshot.session_source.get_agent_role(),
|
|
/*is_closed*/ false,
|
|
);
|
|
let event = Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: config_snapshot.model,
|
|
model_provider_id: config_snapshot.model_provider_id,
|
|
service_tier: config_snapshot.service_tier,
|
|
approval_policy: config_snapshot.approval_policy,
|
|
approvals_reviewer: config_snapshot.approvals_reviewer,
|
|
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,
|
|
network_proxy: None,
|
|
rollout_path: thread.rollout_path(),
|
|
}),
|
|
};
|
|
let channel =
|
|
ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event);
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
self.thread_event_channels.insert(thread_id, channel);
|
|
let listener_handle = 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;
|
|
}
|
|
};
|
|
app_event_tx.send(AppEvent::ThreadEvent { thread_id, event });
|
|
}
|
|
});
|
|
self.thread_event_listener_tasks
|
|
.insert(thread_id, listener_handle);
|
|
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_protocol::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.personality = Some(personality);
|
|
self.chat_widget.set_personality(personality);
|
|
}
|
|
|
|
fn sync_tui_theme_selection(&mut self, name: String) {
|
|
self.config.tui_theme = Some(name.clone());
|
|
self.chat_widget.set_tui_theme(Some(name));
|
|
}
|
|
|
|
fn restore_runtime_theme_from_config(&self) {
|
|
if let Some(name) = self.config.tui_theme.as_deref()
|
|
&& let Some(theme) =
|
|
crate::render::highlight::resolve_theme_by_name(name, Some(&self.config.codex_home))
|
|
{
|
|
crate::render::highlight::set_syntax_theme(theme);
|
|
return;
|
|
}
|
|
|
|
let auto_theme_name = crate::render::highlight::adaptive_default_theme_name();
|
|
if let Some(theme) = crate::render::highlight::resolve_theme_by_name(
|
|
auto_theme_name,
|
|
Some(&self.config.codex_home),
|
|
) {
|
|
crate::render::highlight::set_syntax_theme(theme);
|
|
}
|
|
}
|
|
|
|
fn personality_label(personality: Personality) -> &'static str {
|
|
match personality {
|
|
Personality::None => "None",
|
|
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(/*items*/ None);
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
|
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
|
|
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
|
|
// agent-switch shortcuts when the composer is empty so we never steal the expected
|
|
// editing behavior for moving across words inside a draft.
|
|
let allow_agent_word_motion_fallback = !self.enhanced_keys_supported
|
|
&& self.chat_widget.composer_text_with_pending().is_empty();
|
|
if self.overlay.is_none()
|
|
&& self.chat_widget.no_modal_or_popup_active()
|
|
// Alt+Left/Right are also natural word-motion keys in the composer. Keep agent
|
|
// fast-switch available only once the draft is empty so editing behavior wins whenever
|
|
// there is text on screen.
|
|
&& self.chat_widget.composer_text_with_pending().is_empty()
|
|
&& previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback)
|
|
{
|
|
if let Some(thread_id) = self.agent_navigation.adjacent_thread_id(
|
|
self.current_displayed_thread_id(),
|
|
AgentNavigationDirection::Previous,
|
|
) {
|
|
let _ = self.select_agent_thread(tui, thread_id).await;
|
|
}
|
|
return;
|
|
}
|
|
if self.overlay.is_none()
|
|
&& self.chat_widget.no_modal_or_popup_active()
|
|
// Mirror the previous-agent rule above: empty drafts may use these keys for thread
|
|
// switching, but non-empty drafts keep them for expected word-wise cursor motion.
|
|
&& self.chat_widget.composer_text_with_pending().is_empty()
|
|
&& next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback)
|
|
{
|
|
if let Some(thread_id) = self.agent_navigation.adjacent_thread_id(
|
|
self.current_displayed_thread_id(),
|
|
AgentNavigationDirection::Next,
|
|
) {
|
|
let _ = self.select_agent_thread(tui, thread_id).await;
|
|
}
|
|
return;
|
|
}
|
|
|
|
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('l'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} => {
|
|
if !self.chat_widget.can_run_ctrl_l_clear_now() {
|
|
return;
|
|
}
|
|
if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) {
|
|
tracing::warn!(error = %err, "failed to clear terminal UI");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to clear terminal UI: {err}"));
|
|
} else {
|
|
self.reset_app_ui_state_after_clear();
|
|
self.queue_clear_ui_header(tui);
|
|
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);
|
|
}
|
|
_ => {
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
};
|
|
}
|
|
|
|
fn refresh_status_surfaces(&mut self) {
|
|
self.chat_widget.refresh_status_surfaces();
|
|
}
|
|
|
|
#[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_protocol::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,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
impl Drop for App {
|
|
fn drop(&mut self) {
|
|
if let Err(err) = self.chat_widget.clear_managed_terminal_title() {
|
|
tracing::debug!(error = %err, "failed to clear terminal title on app drop");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app_backtrack::BacktrackSelection;
|
|
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_backtrack::user_count;
|
|
use crate::bottom_pane::TerminalTitleItem;
|
|
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
|
use crate::chatwidget::tests::set_chatgpt_auth;
|
|
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 crate::multi_agents::AgentPickerThreadEntry;
|
|
use assert_matches::assert_matches;
|
|
use codex_core::CodexAuth;
|
|
use codex_core::config::ConfigBuilder;
|
|
use codex_core::config::ConfigOverrides;
|
|
use codex_core::config::types::ModelAvailabilityNuxConfig;
|
|
use codex_otel::SessionTelemetry;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::CollaborationModeMask;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::openai_models::ModelAvailabilityNux;
|
|
use codex_protocol::protocol::AgentMessageDeltaEvent;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionConfiguredEvent;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::ThreadRolledBackEvent;
|
|
use codex_protocol::protocol::TurnAbortReason;
|
|
use codex_protocol::protocol::TurnAbortedEvent;
|
|
use codex_protocol::protocol::TurnCompleteEvent;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_protocol::protocol::UserMessageEvent;
|
|
use codex_protocol::user_input::TextElement;
|
|
use codex_protocol::user_input::UserInput;
|
|
use crossterm::event::KeyModifiers;
|
|
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(())
|
|
}
|
|
|
|
#[test]
|
|
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
|
|
assert_eq!(
|
|
App::should_wait_for_initial_session(&SessionSelection::StartFresh),
|
|
true
|
|
);
|
|
assert_eq!(
|
|
App::should_wait_for_initial_session(&SessionSelection::Exit),
|
|
true
|
|
);
|
|
assert_eq!(
|
|
App::should_wait_for_initial_session(&SessionSelection::Resume(
|
|
crate::resume_picker::SessionTarget {
|
|
path: PathBuf::from("/tmp/restore"),
|
|
thread_id: ThreadId::new(),
|
|
}
|
|
)),
|
|
false
|
|
);
|
|
assert_eq!(
|
|
App::should_wait_for_initial_session(&SessionSelection::Fork(
|
|
crate::resume_picker::SessionTarget {
|
|
path: PathBuf::from("/tmp/fork"),
|
|
thread_id: ThreadId::new(),
|
|
}
|
|
)),
|
|
false
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() {
|
|
let mut wait_for_initial_session =
|
|
App::should_wait_for_initial_session(&SessionSelection::StartFresh);
|
|
assert_eq!(wait_for_initial_session, true);
|
|
assert_eq!(
|
|
App::should_handle_active_thread_events(wait_for_initial_session, true),
|
|
false
|
|
);
|
|
|
|
assert_eq!(
|
|
App::should_stop_waiting_for_initial_session(wait_for_initial_session, None),
|
|
false
|
|
);
|
|
if App::should_stop_waiting_for_initial_session(
|
|
wait_for_initial_session,
|
|
Some(ThreadId::new()),
|
|
) {
|
|
wait_for_initial_session = false;
|
|
}
|
|
assert_eq!(wait_for_initial_session, false);
|
|
|
|
assert_eq!(
|
|
App::should_handle_active_thread_events(wait_for_initial_session, true),
|
|
true
|
|
);
|
|
}
|
|
|
|
fn render_history_cell(cell: &dyn HistoryCell, width: u16) -> String {
|
|
cell.display_lines(width)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_custom_prompt_deprecation_notice_emits_when_prompts_exist() -> Result<()> {
|
|
let codex_home = tempdir()?;
|
|
let prompts_dir = codex_home.path().join("prompts");
|
|
std::fs::create_dir_all(&prompts_dir)?;
|
|
std::fs::write(prompts_dir.join("review.md"), "# Review\n")?;
|
|
|
|
let (tx_raw, mut rx) = unbounded_channel();
|
|
let app_event_tx = AppEventSender::new(tx_raw);
|
|
|
|
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
|
|
|
|
let cell = match rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = render_history_cell(cell.as_ref(), 120);
|
|
|
|
assert_snapshot!("startup_custom_prompt_deprecation_notice", rendered);
|
|
assert!(rx.try_recv().is_err(), "expected only one startup notice");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_custom_prompt_deprecation_notice_skips_missing_prompts_dir() -> Result<()> {
|
|
let codex_home = tempdir()?;
|
|
let (tx_raw, mut rx) = unbounded_channel();
|
|
let app_event_tx = AppEventSender::new(tx_raw);
|
|
|
|
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
|
|
|
|
assert!(rx.try_recv().is_err(), "expected no startup notice");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn startup_custom_prompt_deprecation_notice_skips_empty_prompts_dir() -> Result<()> {
|
|
let codex_home = tempdir()?;
|
|
std::fs::create_dir_all(codex_home.path().join("prompts"))?;
|
|
let (tx_raw, mut rx) = unbounded_channel();
|
|
let app_event_tx = AppEventSender::new(tx_raw);
|
|
|
|
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
|
|
|
|
assert!(rx.try_recv().is_err(), "expected no startup notice");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
|
|
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(
|
|
crate::resume_picker::SessionTarget {
|
|
path: PathBuf::from("/tmp/restore"),
|
|
thread_id: ThreadId::new(),
|
|
},
|
|
));
|
|
assert_eq!(
|
|
App::should_handle_active_thread_events(wait_for_resume, true),
|
|
true
|
|
);
|
|
let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork(
|
|
crate::resume_picker::SessionTarget {
|
|
path: PathBuf::from("/tmp/fork"),
|
|
thread_id: ThreadId::new(),
|
|
},
|
|
));
|
|
assert_eq!(
|
|
App::should_handle_active_thread_events(wait_for_fork, true),
|
|
true
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enqueue_primary_event_delivers_session_configured_before_buffered_approval()
|
|
-> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let approval_event = Event {
|
|
id: "approval-event".to_string(),
|
|
msg: EventMsg::ExecApprovalRequest(
|
|
codex_protocol::protocol::ExecApprovalRequestEvent {
|
|
call_id: "call-1".to_string(),
|
|
approval_id: None,
|
|
turn_id: "turn-1".to_string(),
|
|
command: vec!["echo".to_string(), "hello".to_string()],
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reason: Some("needs approval".to_string()),
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
skill_metadata: None,
|
|
available_decisions: None,
|
|
parsed_cmd: Vec::new(),
|
|
},
|
|
),
|
|
};
|
|
let session_configured_event = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
|
|
app.enqueue_primary_event(approval_event.clone()).await?;
|
|
app.enqueue_primary_event(session_configured_event.clone())
|
|
.await?;
|
|
|
|
let rx = app
|
|
.active_thread_rx
|
|
.as_mut()
|
|
.expect("primary thread receiver should be active");
|
|
let first_event = time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for session configured event")
|
|
.expect("channel closed unexpectedly");
|
|
let second_event = time::timeout(Duration::from_millis(50), rx.recv())
|
|
.await
|
|
.expect("timed out waiting for buffered approval event")
|
|
.expect("channel closed unexpectedly");
|
|
|
|
assert!(matches!(first_event.msg, EventMsg::SessionConfigured(_)));
|
|
assert!(matches!(second_event.msg, EventMsg::ExecApprovalRequest(_)));
|
|
|
|
app.handle_codex_event_now(first_event);
|
|
app.handle_codex_event_now(second_event);
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
while let Ok(app_event) = app_event_rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
thread_id: op_thread_id,
|
|
..
|
|
} = app_event
|
|
{
|
|
assert_eq!(op_thread_id, thread_id);
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
panic!("expected approval action to submit a thread-scoped op");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn routed_thread_event_does_not_recreate_channel_after_reset() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY),
|
|
);
|
|
|
|
app.reset_thread_event_state();
|
|
app.handle_routed_thread_event(
|
|
thread_id,
|
|
Event {
|
|
id: "stale-event".to_string(),
|
|
msg: EventMsg::ShutdownComplete,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
assert!(
|
|
!app.thread_event_channels.contains_key(&thread_id),
|
|
"stale routed events should not recreate cleared thread channels"
|
|
);
|
|
assert_eq!(app.active_thread_id, None);
|
|
assert_eq!(app.primary_thread_id, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_thread_event_state_aborts_listener_tasks() {
|
|
struct NotifyOnDrop(Option<tokio::sync::oneshot::Sender<()>>);
|
|
|
|
impl Drop for NotifyOnDrop {
|
|
fn drop(&mut self) {
|
|
if let Some(tx) = self.0.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
|
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
|
|
let handle = tokio::spawn(async move {
|
|
let _notify_on_drop = NotifyOnDrop(Some(dropped_tx));
|
|
let _ = started_tx.send(());
|
|
std::future::pending::<()>().await;
|
|
});
|
|
app.thread_event_listener_tasks.insert(thread_id, handle);
|
|
started_rx
|
|
.await
|
|
.expect("listener task should report it started");
|
|
|
|
app.reset_thread_event_state();
|
|
|
|
assert_eq!(app.thread_event_listener_tasks.is_empty(), true);
|
|
time::timeout(Duration::from_millis(50), dropped_rx)
|
|
.await
|
|
.expect("timed out waiting for listener task abort")
|
|
.expect("listener task drop notification should succeed");
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_draft_and_queued_input() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session_configured(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
},
|
|
),
|
|
);
|
|
app.activate_thread_channel(thread_id).await;
|
|
|
|
app.chat_widget
|
|
.apply_external_edit("draft prompt".to_string());
|
|
app.chat_widget.submit_user_message_with_mode(
|
|
"queued follow-up".to_string(),
|
|
CollaborationModeMask {
|
|
name: "Default".to_string(),
|
|
mode: None,
|
|
model: None,
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
);
|
|
let expected_input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected thread input state");
|
|
|
|
app.store_active_thread_receiver().await;
|
|
|
|
let snapshot = {
|
|
let channel = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel should exist");
|
|
let store = channel.store.lock().await;
|
|
assert_eq!(store.input_state, Some(expected_input_state));
|
|
store.snapshot()
|
|
};
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
|
|
app.replay_thread_snapshot(snapshot, true);
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
|
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued follow-up submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_turn_complete_submits_restored_queued_follow_up() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
});
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "agent-delta".to_string(),
|
|
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
|
delta: "streaming".to_string(),
|
|
}),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![Event {
|
|
id: "turn-complete".to_string(),
|
|
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
last_agent_message: None,
|
|
}),
|
|
}],
|
|
input_state: Some(input_state),
|
|
},
|
|
true,
|
|
);
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued follow-up submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_only_thread_keeps_restored_queue_visible() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
});
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "agent-delta".to_string(),
|
|
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
|
delta: "streaming".to_string(),
|
|
}),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![Event {
|
|
id: "turn-complete".to_string(),
|
|
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
last_agent_message: None,
|
|
}),
|
|
}],
|
|
input_state: Some(input_state),
|
|
},
|
|
false,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"replay-only threads should not auto-submit restored queue"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_snapshot() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
});
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "agent-delta".to_string(),
|
|
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
|
delta: "streaming".to_string(),
|
|
}),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![],
|
|
input_state: Some(input_state),
|
|
},
|
|
true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"restored queue should stay queued when replay did not prove the turn finished"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
});
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "agent-delta".to_string(),
|
|
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
|
delta: "streaming".to_string(),
|
|
}),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![
|
|
Event {
|
|
id: "older-turn-complete".to_string(),
|
|
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "turn-0".to_string(),
|
|
last_agent_message: None,
|
|
}),
|
|
},
|
|
Event {
|
|
id: "latest-turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
},
|
|
],
|
|
input_state: Some(input_state),
|
|
},
|
|
true,
|
|
);
|
|
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"queued follow-up should stay queued until the latest turn completes"
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.queued_user_message_texts(),
|
|
vec!["queued follow-up".to_string()]
|
|
);
|
|
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "latest-turn-complete".to_string(),
|
|
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
last_agent_message: None,
|
|
}),
|
|
});
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "queued follow-up".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued follow-up submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_chat_widget_preserves_terminal_title_cache_for_empty_replacement_title() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
app.chat_widget.last_terminal_title = Some("my-project | Ready".to_string());
|
|
|
|
let (mut replacement, _app_event_tx, _rx, _new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
replacement.setup_terminal_title(Vec::new());
|
|
|
|
app.replace_chat_widget(replacement);
|
|
|
|
assert_eq!(app.chat_widget.last_terminal_title, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_chat_widget_keeps_replacement_terminal_title_cache_when_present() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
app.chat_widget.last_terminal_title = Some("old-project | Ready".to_string());
|
|
|
|
let (mut replacement, _app_event_tx, _rx, _new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
replacement.setup_terminal_title(vec![TerminalTitleItem::AppName]);
|
|
replacement.last_terminal_title = Some("codex".to_string());
|
|
|
|
app.replace_chat_widget(replacement);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.last_terminal_title,
|
|
Some("codex".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_pending_pastes_for_submit() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels.insert(
|
|
thread_id,
|
|
ThreadEventChannel::new_with_session_configured(
|
|
THREAD_EVENT_CHANNEL_CAPACITY,
|
|
Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
},
|
|
),
|
|
);
|
|
app.activate_thread_channel(thread_id).await;
|
|
|
|
let large = "x".repeat(1005);
|
|
app.chat_widget.handle_paste(large.clone());
|
|
let expected_input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected thread input state");
|
|
|
|
app.store_active_thread_receiver().await;
|
|
|
|
let snapshot = {
|
|
let channel = app
|
|
.thread_event_channels
|
|
.get(&thread_id)
|
|
.expect("thread channel should exist");
|
|
let store = channel.store.lock().await;
|
|
assert_eq!(store.input_state, Some(expected_input_state));
|
|
store.snapshot()
|
|
};
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.replay_thread_snapshot(snapshot, true);
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), large);
|
|
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: large,
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected restored paste submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Plan".to_string(),
|
|
mode: Some(ModeKind::Plan),
|
|
model: Some("gpt-restored".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
|
|
developer_instructions: None,
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("draft prompt".to_string());
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected draft input state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Default".to_string(),
|
|
mode: Some(ModeKind::Default),
|
|
model: Some("gpt-replacement".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::Low)),
|
|
developer_instructions: None,
|
|
});
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![],
|
|
input_state: Some(input_state),
|
|
},
|
|
true,
|
|
);
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match next_user_turn_op(&mut new_op_rx) {
|
|
Op::UserTurn {
|
|
items,
|
|
model,
|
|
effort,
|
|
collaboration_mode,
|
|
..
|
|
} => {
|
|
assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "draft prompt".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
);
|
|
assert_eq!(model, "gpt-restored".to_string());
|
|
assert_eq!(effort, Some(ReasoningEffortConfig::High));
|
|
assert_eq!(
|
|
collaboration_mode,
|
|
Some(CollaborationMode {
|
|
mode: ModeKind::Plan,
|
|
settings: Settings {
|
|
model: "gpt-restored".to_string(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
developer_instructions: None,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
other => panic!("expected restored draft submission, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Plan".to_string(),
|
|
mode: Some(ModeKind::Plan),
|
|
model: Some("gpt-restored".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
|
|
developer_instructions: None,
|
|
});
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected collaboration-only input state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, _new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Low));
|
|
app.chat_widget
|
|
.set_collaboration_mask(CollaborationModeMask {
|
|
name: "Default".to_string(),
|
|
mode: Some(ModeKind::Default),
|
|
model: Some("gpt-replacement".to_string()),
|
|
reasoning_effort: Some(Some(ReasoningEffortConfig::Low)),
|
|
developer_instructions: None,
|
|
});
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![],
|
|
input_state: Some(input_state),
|
|
},
|
|
true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.active_collaboration_mode_kind(),
|
|
ModeKind::Plan
|
|
);
|
|
assert_eq!(app.chat_widget.current_model(), "gpt-restored");
|
|
assert_eq!(
|
|
app.chat_widget.current_reasoning_effort(),
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
|
|
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
let session_configured = Event {
|
|
id: "session-configured".to_string(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
};
|
|
app.chat_widget
|
|
.handle_codex_event(session_configured.clone());
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
});
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: "agent-delta".to_string(),
|
|
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
|
delta: "streaming".to_string(),
|
|
}),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("queued follow-up".to_string());
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
let input_state = app
|
|
.chat_widget
|
|
.capture_thread_input_state()
|
|
.expect("expected queued follow-up state");
|
|
|
|
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
|
make_chatwidget_manual_with_sender().await;
|
|
app.chat_widget = chat_widget;
|
|
app.chat_widget.handle_codex_event(session_configured);
|
|
while new_op_rx.try_recv().is_ok() {}
|
|
|
|
app.replay_thread_snapshot(
|
|
ThreadEventSnapshot {
|
|
session_configured: None,
|
|
events: vec![Event {
|
|
id: "turn-aborted".to_string(),
|
|
msg: EventMsg::TurnAborted(TurnAbortedEvent {
|
|
turn_id: Some("turn-1".to_string()),
|
|
reason: TurnAbortReason::ReviewEnded,
|
|
}),
|
|
}],
|
|
input_state: Some(input_state),
|
|
},
|
|
true,
|
|
);
|
|
|
|
assert_eq!(
|
|
app.chat_widget.composer_text_with_pending(),
|
|
"queued follow-up"
|
|
);
|
|
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
|
assert!(
|
|
new_op_rx.try_recv().is_err(),
|
|
"replayed interrupted turns should restore queued input for editing, not submit it"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_turn_started_refreshes_status_line_with_runtime_context_window() {
|
|
let mut app = make_test_app().await;
|
|
app.chat_widget
|
|
.setup_status_line(vec![crate::bottom_pane::StatusLineItem::ContextWindowSize]);
|
|
|
|
assert_eq!(app.chat_widget.status_line_text(), None);
|
|
|
|
app.handle_codex_event_now(Event {
|
|
id: "turn-started".to_string(),
|
|
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: "turn-1".to_string(),
|
|
model_context_window: Some(950_000),
|
|
collaboration_mode_kind: Default::default(),
|
|
}),
|
|
});
|
|
|
|
assert_eq!(
|
|
app.chat_widget.status_line_text(),
|
|
Some("950K window".into())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(1));
|
|
|
|
app.open_agent_picker().await;
|
|
|
|
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
|
assert_eq!(
|
|
app.agent_navigation.get(&thread_id),
|
|
Some(&AgentPickerThreadEntry {
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
is_closed: true,
|
|
})
|
|
);
|
|
assert_eq!(app.agent_navigation.ordered_thread_ids(), vec![thread_id]);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_agent_picker_keeps_cached_closed_threads() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(1));
|
|
app.agent_navigation.upsert(
|
|
thread_id,
|
|
Some("Robie".to_string()),
|
|
Some("explorer".to_string()),
|
|
false,
|
|
);
|
|
|
|
app.open_agent_picker().await;
|
|
|
|
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
|
|
assert_eq!(
|
|
app.agent_navigation.get(&thread_id),
|
|
Some(&AgentPickerThreadEntry {
|
|
agent_nickname: Some("Robie".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
is_closed: true,
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let _ = app.config.features.disable(Feature::Collab);
|
|
|
|
app.open_agent_picker().await;
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert_matches!(
|
|
app_event_rx.try_recv(),
|
|
Ok(AppEvent::UpdateFeatureFlags { updates }) if updates == vec![(Feature::Collab, true)]
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Subagents will be enabled in the next session."));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_enabling_guardian_selects_guardian_approvals() -> Result<()> {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
let guardian_approvals = guardian_approvals_mode();
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, true)])
|
|
.await;
|
|
|
|
assert!(app.config.features.enabled(Feature::GuardianApproval));
|
|
assert!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.features
|
|
.enabled(Feature::GuardianApproval)
|
|
);
|
|
assert_eq!(
|
|
app.config.approvals_reviewer,
|
|
guardian_approvals.approvals_reviewer
|
|
);
|
|
assert_eq!(
|
|
app.config.permissions.approval_policy.value(),
|
|
guardian_approvals.approval_policy
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.approval_policy
|
|
.value(),
|
|
guardian_approvals.approval_policy
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.sandbox_policy
|
|
.get(),
|
|
&guardian_approvals.sandbox_policy
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
guardian_approvals.approvals_reviewer
|
|
);
|
|
assert_eq!(app.runtime_approval_policy_override, None);
|
|
assert_eq!(app.runtime_sandbox_policy_override, None);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: Some(guardian_approvals.approval_policy),
|
|
approvals_reviewer: Some(guardian_approvals.approvals_reviewer),
|
|
sandbox_policy: Some(guardian_approvals.sandbox_policy.clone()),
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Permissions updated to Guardian Approvals"));
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(config.contains("guardian_approval = true"));
|
|
assert!(config.contains("approvals_reviewer = \"guardian_subagent\""));
|
|
assert!(config.contains("approval_policy = \"on-request\""));
|
|
assert!(config.contains("sandbox_mode = \"workspace-write\""));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default()
|
|
-> Result<()> {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?;
|
|
let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
|
|
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
|
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
|
app.config.config_layer_stack = app
|
|
.config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config);
|
|
app.config
|
|
.features
|
|
.set_enabled(Feature::GuardianApproval, true)?;
|
|
app.chat_widget
|
|
.set_feature_enabled(Feature::GuardianApproval, true);
|
|
app.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent);
|
|
app.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest)?;
|
|
app.config
|
|
.permissions
|
|
.sandbox_policy
|
|
.set(SandboxPolicy::new_workspace_write_policy())?;
|
|
app.chat_widget
|
|
.set_approval_policy(AskForApproval::OnRequest);
|
|
app.chat_widget
|
|
.set_sandbox_policy(SandboxPolicy::new_workspace_write_policy())?;
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, false)])
|
|
.await;
|
|
|
|
assert!(!app.config.features.enabled(Feature::GuardianApproval));
|
|
assert!(
|
|
!app.chat_widget
|
|
.config_ref()
|
|
.features
|
|
.enabled(Feature::GuardianApproval)
|
|
);
|
|
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
|
|
assert_eq!(
|
|
app.config.permissions.approval_policy.value(),
|
|
AskForApproval::OnRequest
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
ApprovalsReviewer::User
|
|
);
|
|
assert_eq!(app.runtime_approval_policy_override, None);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Permissions updated to Default"));
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("guardian_approval = true"));
|
|
assert!(!config.contains("approvals_reviewer ="));
|
|
assert!(config.contains("approval_policy = \"on-request\""));
|
|
assert!(config.contains("sandbox_mode = \"workspace-write\""));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review_policy()
|
|
-> Result<()> {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
let guardian_approvals = guardian_approvals_mode();
|
|
let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?;
|
|
let config_toml = "approvals_reviewer = \"user\"\n";
|
|
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
|
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
|
app.config.config_layer_stack = app
|
|
.config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config);
|
|
app.config.approvals_reviewer = ApprovalsReviewer::User;
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::User);
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, true)])
|
|
.await;
|
|
|
|
assert!(app.config.features.enabled(Feature::GuardianApproval));
|
|
assert_eq!(
|
|
app.config.approvals_reviewer,
|
|
guardian_approvals.approvals_reviewer
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
guardian_approvals.approvals_reviewer
|
|
);
|
|
assert_eq!(
|
|
app.config.permissions.approval_policy.value(),
|
|
guardian_approvals.approval_policy
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.permissions
|
|
.sandbox_policy
|
|
.get(),
|
|
&guardian_approvals.sandbox_policy
|
|
);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: Some(guardian_approvals.approval_policy),
|
|
approvals_reviewer: Some(guardian_approvals.approvals_reviewer),
|
|
sandbox_policy: Some(guardian_approvals.sandbox_policy.clone()),
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(config.contains("approvals_reviewer = \"guardian_subagent\""));
|
|
assert!(config.contains("guardian_approval = true"));
|
|
assert!(config.contains("approval_policy = \"on-request\""));
|
|
assert!(config.contains("sandbox_mode = \"workspace-write\""));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history()
|
|
-> Result<()> {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?;
|
|
let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n";
|
|
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
|
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
|
app.config.config_layer_stack = app
|
|
.config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config);
|
|
app.config
|
|
.features
|
|
.set_enabled(Feature::GuardianApproval, true)?;
|
|
app.chat_widget
|
|
.set_feature_enabled(Feature::GuardianApproval, true);
|
|
app.config.approvals_reviewer = ApprovalsReviewer::User;
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::User);
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, false)])
|
|
.await;
|
|
|
|
assert!(!app.config.features.enabled(Feature::GuardianApproval));
|
|
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
ApprovalsReviewer::User
|
|
);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
assert!(
|
|
app_event_rx.try_recv().is_err(),
|
|
"manual review should not emit a permissions history update when the effective state stays default"
|
|
);
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("guardian_approval = true"));
|
|
assert!(!config.contains("approvals_reviewer ="));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_review_policy()
|
|
-> Result<()> {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
let guardian_approvals = guardian_approvals_mode();
|
|
app.active_profile = Some("guardian".to_string());
|
|
let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?;
|
|
let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n";
|
|
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
|
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
|
app.config.config_layer_stack = app
|
|
.config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config);
|
|
app.config.approvals_reviewer = ApprovalsReviewer::User;
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::User);
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, true)])
|
|
.await;
|
|
|
|
assert!(app.config.features.enabled(Feature::GuardianApproval));
|
|
assert_eq!(
|
|
app.config.approvals_reviewer,
|
|
guardian_approvals.approvals_reviewer
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
guardian_approvals.approvals_reviewer
|
|
);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: Some(guardian_approvals.approval_policy),
|
|
approvals_reviewer: Some(guardian_approvals.approvals_reviewer),
|
|
sandbox_policy: Some(guardian_approvals.sandbox_policy.clone()),
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
let config_value = toml::from_str::<TomlValue>(&config)?;
|
|
let profile_config = config_value
|
|
.as_table()
|
|
.and_then(|table| table.get("profiles"))
|
|
.and_then(TomlValue::as_table)
|
|
.and_then(|profiles| profiles.get("guardian"))
|
|
.and_then(TomlValue::as_table)
|
|
.expect("guardian profile should exist");
|
|
assert_eq!(
|
|
config_value
|
|
.as_table()
|
|
.and_then(|table| table.get("approvals_reviewer")),
|
|
Some(&TomlValue::String("user".to_string()))
|
|
);
|
|
assert_eq!(
|
|
profile_config.get("approvals_reviewer"),
|
|
Some(&TomlValue::String("guardian_subagent".to_string()))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_disabling_guardian_in_profile_allows_inherited_user_reviewer()
|
|
-> Result<()> {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
app.active_profile = Some("guardian".to_string());
|
|
let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?;
|
|
let config_toml = r#"
|
|
profile = "guardian"
|
|
approvals_reviewer = "user"
|
|
|
|
[profiles.guardian]
|
|
approvals_reviewer = "guardian_subagent"
|
|
|
|
[profiles.guardian.features]
|
|
guardian_approval = true
|
|
"#;
|
|
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
|
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
|
app.config.config_layer_stack = app
|
|
.config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config);
|
|
app.config
|
|
.features
|
|
.set_enabled(Feature::GuardianApproval, true)?;
|
|
app.chat_widget
|
|
.set_feature_enabled(Feature::GuardianApproval, true);
|
|
app.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent);
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, false)])
|
|
.await;
|
|
|
|
assert!(!app.config.features.enabled(Feature::GuardianApproval));
|
|
assert!(
|
|
!app.chat_widget
|
|
.config_ref()
|
|
.features
|
|
.enabled(Feature::GuardianApproval)
|
|
);
|
|
assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
ApprovalsReviewer::User
|
|
);
|
|
assert_eq!(
|
|
op_rx.try_recv(),
|
|
Ok(Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: None,
|
|
windows_sandbox_level: None,
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
service_tier: None,
|
|
collaboration_mode: None,
|
|
personality: None,
|
|
})
|
|
);
|
|
let cell = match app_event_rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
|
other => panic!("expected InsertHistoryCell event, got {other:?}"),
|
|
};
|
|
let rendered = cell
|
|
.display_lines(120)
|
|
.into_iter()
|
|
.map(|line| line.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("Permissions updated to Default"));
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("guardian_approval = true"));
|
|
assert!(!config.contains("guardian_subagent"));
|
|
assert_eq!(
|
|
toml::from_str::<TomlValue>(&config)?
|
|
.as_table()
|
|
.and_then(|table| table.get("approvals_reviewer")),
|
|
Some(&TomlValue::String("user".to_string()))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_feature_flags_disabling_guardian_in_profile_keeps_inherited_non_user_reviewer_enabled()
|
|
-> Result<()> {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
app.active_profile = Some("guardian".to_string());
|
|
let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?;
|
|
let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n";
|
|
std::fs::write(config_toml_path.as_path(), config_toml)?;
|
|
let user_config = toml::from_str::<TomlValue>(config_toml)?;
|
|
app.config.config_layer_stack = app
|
|
.config
|
|
.config_layer_stack
|
|
.with_user_config(&config_toml_path, user_config);
|
|
app.config
|
|
.features
|
|
.set_enabled(Feature::GuardianApproval, true)?;
|
|
app.chat_widget
|
|
.set_feature_enabled(Feature::GuardianApproval, true);
|
|
app.config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent;
|
|
app.chat_widget
|
|
.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent);
|
|
|
|
app.update_feature_flags(vec![(Feature::GuardianApproval, false)])
|
|
.await;
|
|
|
|
assert!(app.config.features.enabled(Feature::GuardianApproval));
|
|
assert!(
|
|
app.chat_widget
|
|
.config_ref()
|
|
.features
|
|
.enabled(Feature::GuardianApproval)
|
|
);
|
|
assert_eq!(
|
|
app.config.approvals_reviewer,
|
|
ApprovalsReviewer::GuardianSubagent
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().approvals_reviewer,
|
|
ApprovalsReviewer::GuardianSubagent
|
|
);
|
|
assert!(
|
|
op_rx.try_recv().is_err(),
|
|
"disabling an inherited non-user reviewer should not patch the active session"
|
|
);
|
|
let app_events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::<Vec<_>>();
|
|
assert!(
|
|
!app_events.iter().any(|event| match event {
|
|
AppEvent::InsertHistoryCell(cell) => cell
|
|
.display_lines(120)
|
|
.iter()
|
|
.any(|line| line.to_string().contains("Permissions updated to")),
|
|
_ => false,
|
|
}),
|
|
"blocking disable with inherited guardian review should not emit a permissions history update: {app_events:?}"
|
|
);
|
|
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(config.contains("guardian_approval = true"));
|
|
assert_eq!(
|
|
toml::from_str::<TomlValue>(&config)?
|
|
.as_table()
|
|
.and_then(|table| table.get("approvals_reviewer")),
|
|
Some(&TomlValue::String("guardian_subagent".to_string()))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
|
|
{
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
app.thread_event_channels
|
|
.insert(thread_id, ThreadEventChannel::new(1));
|
|
|
|
app.open_agent_picker().await;
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert_matches!(
|
|
app_event_rx.try_recv(),
|
|
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
|
|
let mut app = make_test_app().await;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
|
|
let agent_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
|
|
|
|
app.primary_thread_id = Some(main_thread_id);
|
|
app.active_thread_id = Some(main_thread_id);
|
|
app.thread_event_channels
|
|
.insert(main_thread_id, ThreadEventChannel::new(1));
|
|
|
|
let agent_channel = ThreadEventChannel::new(1);
|
|
{
|
|
let mut store = agent_channel.store.lock().await;
|
|
store.push_event(Event {
|
|
id: "ev-1".to_string(),
|
|
msg: EventMsg::ExecApprovalRequest(
|
|
codex_protocol::protocol::ExecApprovalRequestEvent {
|
|
call_id: "call-1".to_string(),
|
|
approval_id: None,
|
|
turn_id: "turn-1".to_string(),
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
cwd: PathBuf::from("/tmp"),
|
|
reason: None,
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
skill_metadata: None,
|
|
available_decisions: None,
|
|
parsed_cmd: Vec::new(),
|
|
},
|
|
),
|
|
});
|
|
}
|
|
app.thread_event_channels
|
|
.insert(agent_thread_id, agent_channel);
|
|
app.agent_navigation.upsert(
|
|
agent_thread_id,
|
|
Some("Robie".to_string()),
|
|
Some("explorer".to_string()),
|
|
false,
|
|
);
|
|
|
|
app.refresh_pending_thread_approvals().await;
|
|
assert_eq!(
|
|
app.chat_widget.pending_thread_approvals(),
|
|
&["Robie [explorer]".to_string()]
|
|
);
|
|
|
|
app.active_thread_id = Some(agent_thread_id);
|
|
app.refresh_pending_thread_approvals().await;
|
|
assert!(app.chat_widget.pending_thread_approvals().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let main_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
|
|
let agent_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
|
|
|
|
app.primary_thread_id = Some(main_thread_id);
|
|
app.active_thread_id = Some(main_thread_id);
|
|
app.thread_event_channels
|
|
.insert(main_thread_id, ThreadEventChannel::new(1));
|
|
app.thread_event_channels.insert(
|
|
agent_thread_id,
|
|
ThreadEventChannel::new_with_session_configured(
|
|
1,
|
|
Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: agent_thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-5".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::OnRequest,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_workspace_write_policy(),
|
|
cwd: PathBuf::from("/tmp/agent"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::from("/tmp/agent-rollout.jsonl")),
|
|
}),
|
|
},
|
|
),
|
|
);
|
|
app.agent_navigation.upsert(
|
|
agent_thread_id,
|
|
Some("Robie".to_string()),
|
|
Some("explorer".to_string()),
|
|
false,
|
|
);
|
|
|
|
app.enqueue_thread_event(
|
|
agent_thread_id,
|
|
Event {
|
|
id: "ev-approval".to_string(),
|
|
msg: EventMsg::ExecApprovalRequest(
|
|
codex_protocol::protocol::ExecApprovalRequestEvent {
|
|
call_id: "call-approval".to_string(),
|
|
approval_id: None,
|
|
turn_id: "turn-approval".to_string(),
|
|
command: vec!["echo".to_string(), "hi".to_string()],
|
|
cwd: PathBuf::from("/tmp/agent"),
|
|
reason: Some("need approval".to_string()),
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
skill_metadata: None,
|
|
available_decisions: None,
|
|
parsed_cmd: Vec::new(),
|
|
},
|
|
),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(app.chat_widget.has_active_view(), true);
|
|
assert_eq!(
|
|
app.chat_widget.pending_thread_approvals(),
|
|
&["Robie [explorer]".to_string()]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn agent_picker_item_name_snapshot() {
|
|
let thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
|
|
let snapshot = [
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(Some("Robie"), Some("explorer"), true),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(Some("Robie"), Some("explorer"), false),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(Some("Robie"), None, false),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(None, Some("explorer"), false),
|
|
thread_id
|
|
),
|
|
format!(
|
|
"{} | {}",
|
|
format_agent_picker_item_name(None, None, false),
|
|
thread_id
|
|
),
|
|
]
|
|
.join("\n");
|
|
assert_snapshot!("agent_picker_item_name", snapshot);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()>
|
|
{
|
|
let mut app = make_test_app().await;
|
|
app.active_thread_id = Some(ThreadId::new());
|
|
app.primary_thread_id = Some(ThreadId::new());
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&EventMsg::SkillsUpdateAvailable),
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_none_for_primary_thread_shutdown()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
app.primary_thread_id = Some(thread_id);
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_ids_for_non_primary_shutdown() -> Result<()>
|
|
{
|
|
let mut app = make_test_app().await;
|
|
let active_thread_id = ThreadId::new();
|
|
let primary_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(active_thread_id);
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
|
|
Some((active_thread_id, primary_thread_id))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_returns_none_when_shutdown_exit_is_pending()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let active_thread_id = ThreadId::new();
|
|
let primary_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(active_thread_id);
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
app.pending_shutdown_exit_thread_id = Some(active_thread_id);
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
|
|
None
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn active_non_primary_shutdown_target_still_switches_for_other_pending_exit_thread()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let active_thread_id = ThreadId::new();
|
|
let primary_thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(active_thread_id);
|
|
app.primary_thread_id = Some(primary_thread_id);
|
|
app.pending_shutdown_exit_thread_id = Some(ThreadId::new());
|
|
|
|
assert_eq!(
|
|
app.active_non_primary_shutdown_target(&EventMsg::ShutdownComplete),
|
|
Some((active_thread_id, primary_thread_id))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
|
|
let mut app = make_test_app().await;
|
|
app.config.cwd = PathBuf::from("/tmp/project");
|
|
app.chat_widget.set_model("gpt-test");
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \
|
|
nineteen years, and the children were told it was because the sea no longer wanted a \
|
|
guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \
|
|
heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \
|
|
mouth of the bay as if listening for a signal that never came. When an old brass key fell \
|
|
out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \
|
|
she decided to climb the hill and see what the town had forgotten.";
|
|
let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \
|
|
with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \
|
|
broken, only unfinished. Someone had removed the governor spring and hidden it in a false \
|
|
drawer, along with a letter from the last keeper admitting he had darkened the light on \
|
|
purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \
|
|
from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \
|
|
over the water and the harbor bells began to ring.";
|
|
let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \
|
|
blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \
|
|
great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \
|
|
whitecaps, and reached the boats just as they were drifting toward the rocks below the \
|
|
eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \
|
|
and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \
|
|
and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \
|
|
lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \
|
|
something in town to keep better time.";
|
|
|
|
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) 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| -> Arc<dyn HistoryCell> {
|
|
let event = SessionConfiguredEvent {
|
|
session_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: Some(ReasoningEffortConfig::High),
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.chat_widget.current_model(),
|
|
event,
|
|
is_first,
|
|
None,
|
|
None,
|
|
false,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
app.transcript_cells = vec![
|
|
make_header(true),
|
|
Arc::new(crate::history_cell::new_info_event(
|
|
"startup tip that used to replay".to_string(),
|
|
/*hint*/ None,
|
|
)) as Arc<dyn HistoryCell>,
|
|
user_cell("Tell me a long story about a town with a dark lighthouse."),
|
|
agent_cell(story_part_one),
|
|
user_cell("Continue the story and reveal why the light went out."),
|
|
agent_cell(story_part_two),
|
|
user_cell("Finish the story with a storm and a resolution."),
|
|
agent_cell(story_part_three),
|
|
];
|
|
app.has_emitted_history_lines = true;
|
|
|
|
let rendered = app
|
|
.clear_ui_header_lines_with_version(80, "<VERSION>")
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
assert!(
|
|
!rendered.contains("startup tip that used to replay"),
|
|
"clear header should not replay startup notices"
|
|
);
|
|
assert!(
|
|
!rendered.contains("Bracken Ferry"),
|
|
"clear header should not replay prior conversation turns"
|
|
);
|
|
rendered
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() {
|
|
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
|
|
assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() {
|
|
let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await;
|
|
assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_ui_header_shows_fast_status_only_for_gpt54() {
|
|
let mut app = make_test_app().await;
|
|
app.config.cwd = PathBuf::from("/tmp/project");
|
|
app.chat_widget.set_model("gpt-5.4");
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
|
app.chat_widget
|
|
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
|
|
set_chatgpt_auth(&mut app.chat_widget);
|
|
|
|
let rendered = app
|
|
.clear_ui_header_lines_with_version(80, "<VERSION>")
|
|
.iter()
|
|
.map(|line| {
|
|
line.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered);
|
|
}
|
|
|
|
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(
|
|
codex_core::test_support::thread_manager_with_models_provider(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
config.model_provider.clone(),
|
|
),
|
|
);
|
|
let auth_manager = codex_core::test_support::auth_manager_from_auth(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
);
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
|
|
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
|
|
|
App {
|
|
server,
|
|
session_telemetry,
|
|
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)),
|
|
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
backtrack_render_pending: false,
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
feedback_audience: FeedbackAudience::External,
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
pending_shutdown_exit_thread_id: None,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
thread_event_listener_tasks: HashMap::new(),
|
|
agent_navigation: AgentNavigationState::default(),
|
|
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(
|
|
codex_core::test_support::thread_manager_with_models_provider(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
config.model_provider.clone(),
|
|
),
|
|
);
|
|
let auth_manager = codex_core::test_support::auth_manager_from_auth(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
);
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
|
|
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
|
|
|
(
|
|
App {
|
|
server,
|
|
session_telemetry,
|
|
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)),
|
|
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
backtrack_render_pending: false,
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
feedback_audience: FeedbackAudience::External,
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
pending_shutdown_exit_thread_id: None,
|
|
windows_sandbox: WindowsSandboxState::default(),
|
|
thread_event_channels: HashMap::new(),
|
|
thread_event_listener_tasks: HashMap::new(),
|
|
agent_navigation: AgentNavigationState::default(),
|
|
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 next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
|
|
let mut seen = Vec::new();
|
|
while let Ok(op) = op_rx.try_recv() {
|
|
if matches!(op, Op::UserTurn { .. }) {
|
|
return op;
|
|
}
|
|
seen.push(format!("{op:?}"));
|
|
}
|
|
panic!("expected UserTurn op, saw: {seen:?}");
|
|
}
|
|
|
|
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
|
|
let model_info = codex_core::test_support::construct_model_info_offline(model, config);
|
|
SessionTelemetry::new(
|
|
ThreadId::new(),
|
|
model,
|
|
model_info.slug.as_str(),
|
|
None,
|
|
None,
|
|
None,
|
|
"test_originator".to_string(),
|
|
false,
|
|
"test".to_string(),
|
|
SessionSource::Cli,
|
|
)
|
|
}
|
|
|
|
fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option<bool> {
|
|
config
|
|
.config_layer_stack
|
|
.effective_config()
|
|
.as_table()
|
|
.and_then(|table| table.get("apps"))
|
|
.and_then(TomlValue::as_table)
|
|
.and_then(|apps| apps.get(app_id))
|
|
.and_then(TomlValue::as_table)
|
|
.and_then(|app| app.get("enabled"))
|
|
.and_then(TomlValue::as_bool)
|
|
}
|
|
|
|
fn all_model_presets() -> Vec<ModelPreset> {
|
|
codex_core::test_support::all_model_presets().clone()
|
|
}
|
|
|
|
fn model_availability_nux_config(shown_count: &[(&str, u32)]) -> ModelAvailabilityNuxConfig {
|
|
ModelAvailabilityNuxConfig {
|
|
shown_count: shown_count
|
|
.iter()
|
|
.map(|(model, count)| ((*model).to_string(), *count))
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
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.2-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex",
|
|
"gpt-5.2-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex-mini",
|
|
"gpt-5.2-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.2-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn select_model_availability_nux_picks_only_eligible_model() {
|
|
let mut presets = all_model_presets();
|
|
presets.iter_mut().for_each(|preset| {
|
|
preset.availability_nux = None;
|
|
});
|
|
let target = presets
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5")
|
|
.expect("target preset present");
|
|
target.availability_nux = Some(ModelAvailabilityNux {
|
|
message: "gpt-5 is available".to_string(),
|
|
});
|
|
|
|
let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
|
|
|
|
assert_eq!(
|
|
selected,
|
|
Some(StartupTooltipOverride {
|
|
model_slug: "gpt-5".to_string(),
|
|
message: "gpt-5 is available".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn select_model_availability_nux_skips_missing_and_exhausted_models() {
|
|
let mut presets = all_model_presets();
|
|
presets.iter_mut().for_each(|preset| {
|
|
preset.availability_nux = None;
|
|
});
|
|
let gpt_5 = presets
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5")
|
|
.expect("gpt-5 preset present");
|
|
gpt_5.availability_nux = Some(ModelAvailabilityNux {
|
|
message: "gpt-5 is available".to_string(),
|
|
});
|
|
let gpt_5_2 = presets
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5.2")
|
|
.expect("gpt-5.2 preset present");
|
|
gpt_5_2.availability_nux = Some(ModelAvailabilityNux {
|
|
message: "gpt-5.2 is available".to_string(),
|
|
});
|
|
|
|
let selected = select_model_availability_nux(
|
|
&presets,
|
|
&model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
|
|
);
|
|
|
|
assert_eq!(
|
|
selected,
|
|
Some(StartupTooltipOverride {
|
|
model_slug: "gpt-5.2".to_string(),
|
|
message: "gpt-5.2 is available".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn select_model_availability_nux_uses_existing_model_order_as_priority() {
|
|
let mut presets = all_model_presets();
|
|
presets.iter_mut().for_each(|preset| {
|
|
preset.availability_nux = None;
|
|
});
|
|
let first = presets
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5")
|
|
.expect("gpt-5 preset present");
|
|
first.availability_nux = Some(ModelAvailabilityNux {
|
|
message: "first".to_string(),
|
|
});
|
|
let second = presets
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5.2")
|
|
.expect("gpt-5.2 preset present");
|
|
second.availability_nux = Some(ModelAvailabilityNux {
|
|
message: "second".to_string(),
|
|
});
|
|
|
|
let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
|
|
|
|
assert_eq!(
|
|
selected,
|
|
Some(StartupTooltipOverride {
|
|
model_slug: "gpt-5.2".to_string(),
|
|
message: "second".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() {
|
|
let mut presets = all_model_presets();
|
|
presets.iter_mut().for_each(|preset| {
|
|
preset.availability_nux = None;
|
|
});
|
|
let target = presets
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5")
|
|
.expect("target preset present");
|
|
target.availability_nux = Some(ModelAvailabilityNux {
|
|
message: "gpt-5 is available".to_string(),
|
|
});
|
|
|
|
let selected = select_model_availability_nux(
|
|
&presets,
|
|
&model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
|
|
);
|
|
|
|
assert_eq!(selected, None);
|
|
}
|
|
|
|
#[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_or_hidden() {
|
|
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());
|
|
|
|
let mut with_hidden_target = all_model_presets();
|
|
let target = with_hidden_target
|
|
.iter_mut()
|
|
.find(|preset| preset.model == "gpt-5.2-codex")
|
|
.expect("target preset present");
|
|
target.show_in_picker = false;
|
|
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5-codex",
|
|
"gpt-5.2-codex",
|
|
&BTreeMap::new(),
|
|
&with_hidden_target,
|
|
));
|
|
assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.2-codex").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 mut 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");
|
|
// Test "hidden current model still prompts" even if bundled
|
|
// catalog data changes the target model's picker visibility.
|
|
available_models
|
|
.iter_mut()
|
|
.find(|preset| preset.model == upgrade.id)
|
|
.expect("upgrade target present")
|
|
.show_in_picker = true;
|
|
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 refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
let app_id = "unit_test_refresh_in_memory_config_connector".to_string();
|
|
|
|
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
|
|
|
ConfigEditsBuilder::new(&app.config.codex_home)
|
|
.with_edits([
|
|
ConfigEdit::SetPath {
|
|
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
|
|
value: false.into(),
|
|
},
|
|
ConfigEdit::SetPath {
|
|
segments: vec![
|
|
"apps".to_string(),
|
|
app_id.clone(),
|
|
"disabled_reason".to_string(),
|
|
],
|
|
value: "user".into(),
|
|
},
|
|
])
|
|
.apply()
|
|
.await
|
|
.expect("persist app toggle");
|
|
|
|
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
|
|
|
app.refresh_in_memory_config_from_disk().await?;
|
|
|
|
assert_eq!(
|
|
app_enabled_in_effective_config(&app.config, &app_id),
|
|
Some(false)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
|
|
let original_config = app.config.clone();
|
|
|
|
app.refresh_in_memory_config_from_disk_best_effort("starting a new thread")
|
|
.await;
|
|
|
|
assert_eq!(app.config, original_config);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_in_memory_config_from_disk_uses_active_chat_widget_cwd() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let original_cwd = app.config.cwd.clone();
|
|
let next_cwd_tmp = tempdir()?;
|
|
let next_cwd = next_cwd_tmp.path().to_path_buf();
|
|
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: next_cwd.clone(),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
});
|
|
|
|
assert_eq!(app.chat_widget.config_ref().cwd, next_cwd);
|
|
assert_eq!(app.config.cwd, original_cwd);
|
|
|
|
app.refresh_in_memory_config_from_disk().await?;
|
|
|
|
assert_eq!(app.config.cwd, app.chat_widget.config_ref().cwd);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error()
|
|
-> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
|
|
let current_config = app.config.clone();
|
|
let current_cwd = current_config.cwd.clone();
|
|
|
|
let resume_config = app
|
|
.rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.clone())
|
|
.await?;
|
|
|
|
assert_eq!(resume_config, current_config);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rebuild_config_for_resume_or_fallback_errors_when_cwd_changes() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf();
|
|
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
|
|
let current_cwd = app.config.cwd.clone();
|
|
let next_cwd_tmp = tempdir()?;
|
|
let next_cwd = next_cwd_tmp.path().to_path_buf();
|
|
|
|
let result = app
|
|
.rebuild_config_for_resume_or_fallback(¤t_cwd, next_cwd)
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_tui_theme_selection_updates_chat_widget_config_copy() {
|
|
let mut app = make_test_app().await;
|
|
|
|
app.sync_tui_theme_selection("dracula".to_string());
|
|
|
|
assert_eq!(app.config.tui_theme.as_deref(), Some("dracula"));
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().tui_theme.as_deref(),
|
|
Some("dracula")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fresh_session_config_uses_current_service_tier() {
|
|
let mut app = make_test_app().await;
|
|
app.chat_widget
|
|
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
|
|
|
|
let config = app.fresh_session_config();
|
|
|
|
assert_eq!(
|
|
config.service_tier,
|
|
Some(codex_protocol::config_types::ServiceTier::Fast)
|
|
);
|
|
}
|
|
|
|
#[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>,
|
|
remote_image_urls: Vec<String>|
|
|
-> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
text_elements,
|
|
local_image_paths,
|
|
remote_image_urls,
|
|
}) 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,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.chat_widget.current_model(),
|
|
event,
|
|
is_first,
|
|
None,
|
|
None,
|
|
false,
|
|
)) 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(), Vec::new()),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()),
|
|
agent_cell("answer follow-up"),
|
|
make_header(false),
|
|
user_cell("first question", Vec::new(), Vec::new(), Vec::new()),
|
|
agent_cell("answer first"),
|
|
user_cell(
|
|
&edited_text,
|
|
edited_text_elements.clone(),
|
|
edited_local_image_paths.clone(),
|
|
vec!["https://example.com/backtrack.png".to_string()],
|
|
),
|
|
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,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: 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);
|
|
assert_eq!(
|
|
selection.remote_image_urls,
|
|
vec!["https://example.com/backtrack.png".to_string()]
|
|
);
|
|
|
|
app.apply_backtrack_rollback(selection);
|
|
assert_eq!(
|
|
app.chat_widget.remote_image_urls(),
|
|
vec!["https://example.com/backtrack.png".to_string()]
|
|
);
|
|
|
|
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 backtrack_remote_image_only_selection_clears_existing_composer_draft() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "original".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>];
|
|
app.chat_widget
|
|
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new());
|
|
|
|
let remote_image_url = "https://example.com/remote-only.png".to_string();
|
|
app.apply_backtrack_rollback(BacktrackSelection {
|
|
nth_user_message: 0,
|
|
prefill: String::new(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: vec![remote_image_url.clone()],
|
|
});
|
|
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
|
|
assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]);
|
|
|
|
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 backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
|
|
let thread_id = ThreadId::new();
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
});
|
|
|
|
let data_image_url = "data:image/png;base64,abc123".to_string();
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "please inspect this".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: vec![data_image_url.clone()],
|
|
}) as Arc<dyn HistoryCell>];
|
|
|
|
app.apply_backtrack_rollback(BacktrackSelection {
|
|
nth_user_message: 0,
|
|
prefill: "please inspect this".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: vec![data_image_url.clone()],
|
|
});
|
|
|
|
app.chat_widget
|
|
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
let mut saw_rollback = false;
|
|
let mut submitted_items: Option<Vec<UserInput>> = None;
|
|
while let Ok(op) = op_rx.try_recv() {
|
|
match op {
|
|
Op::ThreadRollback { .. } => saw_rollback = true,
|
|
Op::UserTurn { items, .. } => submitted_items = Some(items),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(saw_rollback);
|
|
let items = submitted_items.expect("expected user turn after backtrack resubmit");
|
|
assert!(items.iter().any(|item| {
|
|
matches!(
|
|
item,
|
|
UserInput::Image { image_url } if image_url == &data_image_url
|
|
)
|
|
}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_initial_messages_apply_rollback_in_queue_order() {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
|
|
let session_id = ThreadId::new();
|
|
app.handle_codex_event_replay(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: Some(vec![
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
message: "first prompt".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
}),
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
message: "second prompt".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
}),
|
|
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
message: "third prompt".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
}),
|
|
]),
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
});
|
|
|
|
let mut saw_rollback = false;
|
|
while let Ok(event) = app_event_rx.try_recv() {
|
|
match event {
|
|
AppEvent::InsertHistoryCell(cell) => {
|
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
|
app.transcript_cells.push(cell);
|
|
}
|
|
AppEvent::ApplyThreadRollback { num_turns } => {
|
|
saw_rollback = true;
|
|
crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns(
|
|
&mut app.transcript_cells,
|
|
num_turns,
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(saw_rollback);
|
|
let user_messages: Vec<String> = app
|
|
.transcript_cells
|
|
.iter()
|
|
.filter_map(|cell| {
|
|
cell.as_any()
|
|
.downcast_ref::<UserHistoryCell>()
|
|
.map(|cell| cell.message.clone())
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
user_messages,
|
|
vec!["first prompt".to_string(), "third prompt".to_string()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_rollback_during_replay_is_applied_in_app_event_order() {
|
|
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
|
|
|
let session_id = ThreadId::new();
|
|
app.handle_codex_event_replay(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id,
|
|
forked_from_id: None,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: Some(vec![
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
message: "first prompt".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
}),
|
|
EventMsg::UserMessage(UserMessageEvent {
|
|
message: "second prompt".to_string(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
}),
|
|
]),
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
});
|
|
|
|
// Simulate a live rollback arriving before queued replay inserts are drained.
|
|
app.handle_codex_event_now(Event {
|
|
id: "live-rollback".to_string(),
|
|
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
|
|
});
|
|
|
|
let mut saw_rollback = false;
|
|
while let Ok(event) = app_event_rx.try_recv() {
|
|
match event {
|
|
AppEvent::InsertHistoryCell(cell) => {
|
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
|
app.transcript_cells.push(cell);
|
|
}
|
|
AppEvent::ApplyThreadRollback { num_turns } => {
|
|
saw_rollback = true;
|
|
crate::app_backtrack::trim_transcript_cells_drop_last_n_user_turns(
|
|
&mut app.transcript_cells,
|
|
num_turns,
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(saw_rollback);
|
|
let user_messages: Vec<String> = app
|
|
.transcript_cells
|
|
.iter()
|
|
.filter_map(|cell| {
|
|
cell.as_any()
|
|
.downcast_ref::<UserHistoryCell>()
|
|
.map(|cell| cell.message.clone())
|
|
})
|
|
.collect();
|
|
assert_eq!(user_messages, vec!["first prompt".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
|
|
let mut app = make_test_app().await;
|
|
app.transcript_cells = vec![
|
|
Arc::new(UserHistoryCell {
|
|
message: "first".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>,
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from("after first")],
|
|
false,
|
|
)) as Arc<dyn HistoryCell>,
|
|
Arc::new(UserHistoryCell {
|
|
message: "second".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>,
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from("after second")],
|
|
false,
|
|
)) as Arc<dyn HistoryCell>,
|
|
];
|
|
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
|
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
|
app.backtrack.overlay_preview_active = true;
|
|
app.backtrack.nth_user_message = 1;
|
|
|
|
let changed = app.apply_non_pending_thread_rollback(1);
|
|
|
|
assert!(changed);
|
|
assert!(app.backtrack_render_pending);
|
|
assert!(app.deferred_history_lines.is_empty());
|
|
assert_eq!(app.backtrack.nth_user_message, 0);
|
|
let user_messages: Vec<String> = app
|
|
.transcript_cells
|
|
.iter()
|
|
.filter_map(|cell| {
|
|
cell.as_any()
|
|
.downcast_ref::<UserHistoryCell>()
|
|
.map(|cell| cell.message.clone())
|
|
})
|
|
.collect();
|
|
assert_eq!(user_messages, vec!["first".to_string()]);
|
|
let overlay_cell_count = match app.overlay.as_ref() {
|
|
Some(Overlay::Transcript(t)) => t.committed_cell_count(),
|
|
_ => panic!("expected transcript overlay"),
|
|
};
|
|
assert_eq!(overlay_cell_count, app.transcript_cells.len());
|
|
}
|
|
|
|
#[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,
|
|
thread_name: None,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: 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 shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
|
|
let control = app.handle_exit_mode(ExitMode::ShutdownFirst);
|
|
|
|
assert_eq!(app.pending_shutdown_exit_thread_id, None);
|
|
assert!(matches!(
|
|
control,
|
|
AppRunControl::Exit(ExitReason::UserRequested)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shutdown_first_exit_waits_for_shutdown_when_submit_succeeds() {
|
|
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
|
let thread_id = ThreadId::new();
|
|
app.active_thread_id = Some(thread_id);
|
|
|
|
let control = app.handle_exit_mode(ExitMode::ShutdownFirst);
|
|
|
|
assert_eq!(app.pending_shutdown_exit_thread_id, Some(thread_id));
|
|
assert!(matches!(control, AppRunControl::Continue));
|
|
assert_eq!(op_rx.try_recv(), Ok(Op::Shutdown));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_only_ui_reset_preserves_chat_session_state() {
|
|
let mut app = make_test_app().await;
|
|
let thread_id = ThreadId::new();
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
|
session_id: thread_id,
|
|
forked_from_id: None,
|
|
thread_name: Some("keep me".to_string()),
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
}),
|
|
});
|
|
app.chat_widget
|
|
.apply_external_edit("draft prompt".to_string());
|
|
app.transcript_cells = vec![Arc::new(UserHistoryCell {
|
|
message: "old message".to_string(),
|
|
text_elements: Vec::new(),
|
|
local_image_paths: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
}) as Arc<dyn HistoryCell>];
|
|
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
|
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
|
app.has_emitted_history_lines = true;
|
|
app.backtrack.primed = true;
|
|
app.backtrack.overlay_preview_active = true;
|
|
app.backtrack.nth_user_message = 0;
|
|
app.backtrack_render_pending = true;
|
|
|
|
app.reset_app_ui_state_after_clear();
|
|
|
|
assert!(app.overlay.is_none());
|
|
assert!(app.transcript_cells.is_empty());
|
|
assert!(app.deferred_history_lines.is_empty());
|
|
assert!(!app.has_emitted_history_lines);
|
|
assert!(!app.backtrack.primed);
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
assert!(app.backtrack.pending_rollback.is_none());
|
|
assert!(!app.backtrack_render_pending);
|
|
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_summary_skip_zero_usage() {
|
|
assert!(session_summary(TokenUsage::default(), None, 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), None).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())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_summary_prefers_name_over_id() {
|
|
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), Some("my-session".to_string()))
|
|
.expect("summary");
|
|
assert_eq!(
|
|
summary.resume_command,
|
|
Some("codex resume my-session".to_string())
|
|
);
|
|
}
|
|
}
|