mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +00:00
## Why The Codex App has animated pets, but the TUI had no equivalent ambient companion surface. This brings that experience into terminal Codex while keeping the main chat flow usable: the pet should feel present, but it cannot cover transcript text, composer input, approvals, or picker content. The feature also needs to be terminal-aware. Different terminals support different image protocols, tmux can interfere with image rendering, and some users will want pets disabled entirely or anchored differently depending on their layout. <table> <tr><td> <img width="4110" height="2584" alt="CleanShot 2026-05-05 at 12 41 45@2x" src="https://github.com/user-attachments/assets/68a1fcbc-2104-48d6-b834-69c6aaa95cdf" /> <p align="center">macOS - Ghostty, iTerm2 and WezTerm with Custom Pet</p> </td></tr> <tr><td> ![Uploading CleanShot 2026-05-10 at 20.28.30.png…]() <p align="center">Windows Terminal</p> </td></tr> <tr><td> <img width="3902" height="2752" alt="CleanShot 2026-05-05 at 12 39 02@2x" src="https://github.com/user-attachments/assets/300e2931-6b00-467e-91cb-ab8e28470500" /> <p align="center">Linux - WezTerm and Ghostty</p> </td></tr> </table> ## What Changed - Add a TUI ambient pet renderer in `codex-rs/tui/src/pets/`. - Port the app-style pet animation states so the sprite changes with task status, waiting-for-input states, review/ready states, and failures. - Add `/pets` selection UI with a preview pane, loading state, built-in pet choices, and a first-row `Disable terminal pets` option. - Download built-in pet spritesheets on demand from the same public CDN path already used by Android, under `https://persistent.oaistatic.com/codex/pets/v1/...`, and cache them locally under `~/.codex/cache/tui-pets/`. - Keep custom pets local. - Add config support for pet selection, disabling pets, and choosing whether the pet follows the composer bottom or anchors to the terminal bottom. - Reserve layout space around the pet so transcript wrapping, live responses, and composer input do not render underneath the sprite. - Gate image rendering by terminal capability, disable image pets under tmux, and support both Kitty Graphics and SIXEL terminals. - Add redraw cleanup for terminal image artifacts, including sixel cell clearing. ## Current Scope - This is an initial TUI version of ambient pets, not full App parity. - It focuses on ambient sprite rendering, `/pets` selection, custom pets, terminal capability gating, and on-demand CDN-backed built-in assets. - The ambient text overlay is currently disabled, so the TUI renders the pet sprite without extra status text beside it. ## How to Test 1. Start Codex TUI in a terminal with image support. 2. Run `/pets`. 3. Confirm the picker shows built-in pets plus custom pets, and the first item is `Disable terminal pets`. 4. On a fresh `~/.codex/cache/tui-pets/`, move onto a built-in pet and confirm the first preview downloads the spritesheet from the shared Codex pets CDN and renders successfully. 5. Move through the pet list and confirm subsequent built-in previews use the local cache. 6. Select a pet, then send and receive messages. Confirm transcript and composer text wrap before the pet instead of rendering underneath the sprite. 7. Change the pet anchor setting and confirm the pet can either follow the composer bottom or sit at the terminal bottom. 8. Return to `/pets`, choose `Disable terminal pets`, and confirm the sprite disappears cleanly. Targeted tests: - `cargo test -p codex-tui ambient_pet_` - `cargo test -p codex-tui resize_reflow_wraps_transcript_early_when_pet_is_enabled` - `cargo insta pending-snapshots`
777 lines
30 KiB
Rust
777 lines
30 KiB
Rust
//! Runtime configuration persistence helpers for the TUI app.
|
|
//!
|
|
//! This module owns the app-level glue between config.toml edits, in-memory `Config` refreshes,
|
|
//! and the ChatWidget copy of session settings, keeping persistence-heavy code out of the main app
|
|
//! loop.
|
|
|
|
use super::*;
|
|
|
|
impl App {
|
|
pub(super) 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.to_path_buf())
|
|
.cli_overrides(self.cli_kv_overrides.clone())
|
|
.harness_overrides(overrides)
|
|
.build()
|
|
.await
|
|
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
|
}
|
|
|
|
pub(super) 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.to_path_buf())
|
|
.await?;
|
|
self.apply_runtime_policy_overrides(&mut config);
|
|
self.config = config;
|
|
self.chat_widget.sync_plugin_mentions_config(&self.config);
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) 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"
|
|
);
|
|
}
|
|
}
|
|
|
|
pub(super) 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::session_resume::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())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) 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.to_core())
|
|
{
|
|
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(profile) = self.runtime_permission_profile_override.as_ref()
|
|
&& let Err(err) = config.permissions.set_permission_profile(profile.clone())
|
|
{
|
|
tracing::warn!(%err, "failed to carry forward permission profile override");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to carry forward permission profile override: {err}"
|
|
));
|
|
}
|
|
}
|
|
|
|
pub(super) fn set_approvals_reviewer_in_app_and_widget(&mut self, reviewer: ApprovalsReviewer) {
|
|
self.config.approvals_reviewer = reviewer;
|
|
self.chat_widget.set_approvals_reviewer(reviewer);
|
|
}
|
|
|
|
pub(super) 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.to_core()) {
|
|
tracing::warn!(error = %err, "{log_message}");
|
|
self.chat_widget
|
|
.add_error_message(format!("{user_message_prefix}: {err}"));
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
pub(super) fn try_set_permission_profile_on_config(
|
|
&mut self,
|
|
config: &mut Config,
|
|
permission_profile: PermissionProfile,
|
|
user_message_prefix: &str,
|
|
log_message: &str,
|
|
) -> bool {
|
|
if let Err(err) = config
|
|
.permissions
|
|
.set_permission_profile(permission_profile)
|
|
{
|
|
tracing::warn!(error = %err, "{log_message}");
|
|
self.chat_widget
|
|
.add_error_message(format!("{user_message_prefix}: {err}"));
|
|
return false;
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
pub(super) async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) {
|
|
if updates.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let auto_review_preset = auto_review_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 permission_profile_override = None;
|
|
let mut feature_updates_to_apply = Vec::with_capacity(updates.len());
|
|
// Auto-Review 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 Auto-review 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 `/permissions` mode until the user
|
|
// changes it explicitly.
|
|
feature_config.approvals_reviewer = auto_review_preset.approvals_reviewer;
|
|
feature_edits.push(ConfigEdit::SetPath {
|
|
segments: scoped_segments("approvals_reviewer"),
|
|
value: auto_review_preset.approvals_reviewer.to_string().into(),
|
|
});
|
|
if previous_approvals_reviewer != auto_review_preset.approvals_reviewer {
|
|
permissions_history_label = Some("Auto-review");
|
|
}
|
|
} 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 Auto-review
|
|
// 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,
|
|
auto_review_preset.approval_policy,
|
|
"Failed to enable Auto-review",
|
|
"failed to set auto-review approval policy on staged config",
|
|
) {
|
|
continue;
|
|
}
|
|
if !self.try_set_permission_profile_on_config(
|
|
&mut feature_config,
|
|
auto_review_preset.permission_profile.clone(),
|
|
"Failed to enable Auto-review",
|
|
"failed to set auto-review permission profile 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(auto_review_preset.approval_policy);
|
|
permission_profile_override = Some(auto_review_preset.permission_profile.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;
|
|
}
|
|
|
|
let memory_tool_was_enabled = self.config.features.enabled(Feature::MemoryTool);
|
|
self.config = next_config;
|
|
let show_memory_enable_notice =
|
|
feature_updates_to_apply.iter().any(|(feature, enabled)| {
|
|
*feature == Feature::MemoryTool && *enabled && !memory_tool_was_enabled
|
|
});
|
|
for (feature, effective_enabled) in feature_updates_to_apply {
|
|
self.chat_widget
|
|
.set_feature_enabled(feature, effective_enabled);
|
|
}
|
|
if show_memory_enable_notice {
|
|
self.chat_widget.add_memories_enable_notice();
|
|
}
|
|
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(AskForApproval::from(
|
|
self.config.permissions.approval_policy.value(),
|
|
));
|
|
}
|
|
if permission_profile_override.is_some()
|
|
&& let Err(err) = self
|
|
.chat_widget
|
|
.set_permission_profile(self.config.permissions.permission_profile())
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to set auto-review permission profile on chat config"
|
|
);
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to enable Auto-review: {err}"));
|
|
}
|
|
if permission_profile_override.is_some() {
|
|
self.runtime_permission_profile_override =
|
|
Some(self.config.permissions.permission_profile());
|
|
}
|
|
|
|
if approval_policy_override.is_some()
|
|
|| approvals_reviewer_override.is_some()
|
|
|| permission_profile_override.is_some()
|
|
{
|
|
self.sync_active_thread_permission_settings_to_cached_session()
|
|
.await;
|
|
// This uses `OverrideTurnContext` intentionally: toggling the
|
|
// experiment should update the active thread's effective approval
|
|
// settings immediately, just like a `/permissions` selection. Without
|
|
// this runtime patch, the config edit would only affect future
|
|
// sessions or turns recreated from disk.
|
|
let op = AppCommand::override_turn_context(
|
|
/*cwd*/ None,
|
|
approval_policy_override,
|
|
approvals_reviewer_override,
|
|
permission_profile_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(AppCommand::override_turn_context(
|
|
/*cwd*/ None,
|
|
/*approval_policy*/ None,
|
|
/*approvals_reviewer*/ None,
|
|
/*permission_profile*/ None,
|
|
#[cfg(target_os = "windows")]
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
pub(super) async fn update_memory_settings(
|
|
&mut self,
|
|
use_memories: bool,
|
|
generate_memories: bool,
|
|
) -> bool {
|
|
let active_profile = self.active_profile.clone();
|
|
let scoped_memory_segments = |key: &str| {
|
|
if let Some(profile) = active_profile.as_deref() {
|
|
vec![
|
|
"profiles".to_string(),
|
|
profile.to_string(),
|
|
"memories".to_string(),
|
|
key.to_string(),
|
|
]
|
|
} else {
|
|
vec!["memories".to_string(), key.to_string()]
|
|
}
|
|
};
|
|
let edits = [
|
|
ConfigEdit::SetPath {
|
|
segments: scoped_memory_segments("use_memories"),
|
|
value: use_memories.into(),
|
|
},
|
|
ConfigEdit::SetPath {
|
|
segments: scoped_memory_segments("generate_memories"),
|
|
value: generate_memories.into(),
|
|
},
|
|
];
|
|
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_edits(edits)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(error = %err, "failed to persist memory settings");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save memory settings: {err}"));
|
|
return false;
|
|
}
|
|
|
|
self.config.memories.use_memories = use_memories;
|
|
self.config.memories.generate_memories = generate_memories;
|
|
self.chat_widget
|
|
.set_memory_settings(use_memories, generate_memories);
|
|
true
|
|
}
|
|
|
|
pub(super) async fn update_memory_settings_with_app_server(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
use_memories: bool,
|
|
generate_memories: bool,
|
|
) {
|
|
let previous_generate_memories = self.config.memories.generate_memories;
|
|
if !self
|
|
.update_memory_settings(use_memories, generate_memories)
|
|
.await
|
|
{
|
|
return;
|
|
}
|
|
|
|
if previous_generate_memories == generate_memories {
|
|
return;
|
|
}
|
|
|
|
let Some(thread_id) = self.current_displayed_thread_id() else {
|
|
return;
|
|
};
|
|
|
|
let mode = if generate_memories {
|
|
ThreadMemoryMode::Enabled
|
|
} else {
|
|
ThreadMemoryMode::Disabled
|
|
};
|
|
|
|
if let Err(err) = app_server.thread_memory_mode_set(thread_id, mode).await {
|
|
tracing::error!(error = %err, %thread_id, "failed to update thread memory mode");
|
|
self.chat_widget.add_error_message(format!(
|
|
"Saved memory settings, but failed to update the current thread: {err}"
|
|
));
|
|
}
|
|
}
|
|
|
|
pub(super) async fn reset_memories_with_app_server(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
) {
|
|
if let Err(err) = app_server.memory_reset().await {
|
|
tracing::error!(error = %err, "failed to reset memories");
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to reset memories: {err}"));
|
|
return;
|
|
}
|
|
|
|
self.chat_widget
|
|
.add_info_message("Reset local memories.".to_string(), /*hint*/ None);
|
|
}
|
|
|
|
pub(super) 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",
|
|
}
|
|
}
|
|
|
|
pub(super) 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) -> crate::token_usage::TokenUsage {
|
|
self.chat_widget.token_usage()
|
|
}
|
|
|
|
pub(super) 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);
|
|
}
|
|
|
|
pub(super) fn on_update_personality(&mut self, personality: Personality) {
|
|
self.config.personality = Some(personality);
|
|
self.chat_widget.set_personality(personality);
|
|
}
|
|
|
|
pub(super) fn sync_tui_theme_selection(&mut self, name: String) {
|
|
self.config.tui_theme = Some(name.clone());
|
|
self.chat_widget.set_tui_theme(Some(name));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn sync_tui_pet_selection(&mut self, pet: String) {
|
|
self.config.tui_pet = Some(pet.clone());
|
|
self.chat_widget.set_tui_pet(Some(pet));
|
|
}
|
|
|
|
pub(super) fn sync_tui_pet_disabled(&mut self) {
|
|
let pet = crate::pets::DISABLED_PET_ID.to_string();
|
|
self.config.tui_pet = Some(pet.clone());
|
|
self.chat_widget.set_tui_pet(Some(pet));
|
|
}
|
|
|
|
pub(super) 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);
|
|
}
|
|
}
|
|
|
|
pub(super) fn personality_label(personality: Personality) -> &'static str {
|
|
match personality {
|
|
Personality::None => "None",
|
|
Personality::Friendly => "Friendly",
|
|
Personality::Pragmatic => "Pragmatic",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app::test_support::app_enabled_in_effective_config;
|
|
use crate::app::test_support::make_test_app;
|
|
use crate::test_support::PathBufExt;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::tempdir;
|
|
|
|
#[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().abs();
|
|
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().abs();
|
|
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_thread_session(crate::session_state::ThreadSessionState {
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: 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,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: next_cwd.clone().abs(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(PathBuf::new()),
|
|
});
|
|
|
|
assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), 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 refresh_in_memory_config_from_disk_updates_resize_reflow_config() -> Result<()> {
|
|
let mut app = make_test_app().await;
|
|
let codex_home = tempdir()?;
|
|
app.config.codex_home = codex_home.path().to_path_buf().abs();
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"
|
|
[tui]
|
|
terminal_resize_reflow_max_rows = 9000
|
|
"#,
|
|
)?;
|
|
|
|
app.refresh_in_memory_config_from_disk().await?;
|
|
|
|
assert_eq!(
|
|
app.config.terminal_resize_reflow.max_rows,
|
|
crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(9000)
|
|
);
|
|
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().abs();
|
|
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.to_path_buf())
|
|
.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().abs();
|
|
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 sync_tui_pet_selection_updates_chat_widget_config_copy() {
|
|
let mut app = make_test_app().await;
|
|
|
|
app.sync_tui_pet_selection("chefito".to_string());
|
|
|
|
assert_eq!(app.config.tui_pet.as_deref(), Some("chefito"));
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().tui_pet.as_deref(),
|
|
Some("chefito")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_tui_pet_disabled_updates_chat_widget_config_copy() {
|
|
let mut app = make_test_app().await;
|
|
|
|
app.sync_tui_pet_disabled();
|
|
|
|
assert_eq!(
|
|
app.config.tui_pet.as_deref(),
|
|
Some(crate::pets::DISABLED_PET_ID)
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().tui_pet.as_deref(),
|
|
Some(crate::pets::DISABLED_PET_ID)
|
|
);
|
|
}
|
|
}
|