From d4e9556a677bc73304664b42efa574ccf78dab09 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 26 Jan 2026 12:52:13 -0800 Subject: [PATCH] tui: add one-time personality nudge NUX with persisted hide flag --- codex-rs/core/config.schema.json | 4 + codex-rs/core/src/config/edit.rs | 41 +++++++ codex-rs/core/src/config/types.rs | 2 + codex-rs/tui/src/app.rs | 21 ++++ codex-rs/tui/src/app_event.rs | 9 ++ codex-rs/tui/src/chatwidget.rs | 115 +++++++++++++++++- ...idget__tests__personality_nudge_popup.snap | 11 ++ codex-rs/tui/src/chatwidget/tests.rs | 83 +++++++++++++ 8 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_nudge_popup.snap diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6b48dcb4a9..b8940370c6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -456,6 +456,10 @@ "description": "Tracks whether the user has seen the model migration prompt", "type": "boolean" }, + "hide_personality_nudge": { + "description": "Tracks whether the user has already seen the personality selection nudge.", + "type": "boolean" + }, "hide_rate_limit_model_nudge": { "description": "Tracks whether the user opted out of the rate limit model switch reminder.", "type": "boolean" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index a34e689348..1b4d4ccfe2 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -33,6 +33,8 @@ pub enum ConfigEdit { SetNoticeHideWorldWritableWarning(bool), /// Toggle the rate limit model nudge acknowledgement flag. SetNoticeHideRateLimitModelNudge(bool), + /// Toggle the personality selection nudge acknowledgement flag. + SetNoticeHidePersonalityNudge(bool), /// Toggle the Windows onboarding acknowledgement flag. SetWindowsWslSetupAcknowledged(bool), /// Toggle the model migration prompt acknowledgement flag. @@ -296,6 +298,11 @@ impl ConfigDocument { &[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"], value(*acknowledged), )), + ConfigEdit::SetNoticeHidePersonalityNudge(acknowledged) => Ok(self.write_value( + Scope::Global, + &[Notice::TABLE_KEY, "hide_personality_nudge"], + value(*acknowledged), + )), ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { Ok(self.write_value( Scope::Global, @@ -748,6 +755,12 @@ impl ConfigEditsBuilder { self } + pub fn set_hide_personality_nudge(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHidePersonalityNudge(acknowledged)); + self + } + pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetNoticeHideModelMigrationPrompt( @@ -1257,6 +1270,34 @@ hide_rate_limit_model_nudge = true assert_eq!(contents, expected); } + #[test] + fn blocking_set_hide_personality_nudge_preserves_table() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[notice] +existing = "value" +"#, + ) + .expect("seed"); + + apply_blocking( + codex_home, + None, + &[ConfigEdit::SetNoticeHidePersonalityNudge(true)], + ) + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[notice] +existing = "value" +hide_personality_nudge = true +"#; + assert_eq!(contents, expected); + } + #[test] fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 96379dfa03..4b4347e23d 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -480,6 +480,8 @@ pub struct Notice { pub hide_world_writable_warning: Option, /// Tracks whether the user opted out of the rate limit model switch reminder. pub hide_rate_limit_model_nudge: Option, + /// Tracks whether the user has already seen the personality selection nudge. + pub hide_personality_nudge: Option, /// Tracks whether the user has seen the model migration prompt pub hide_gpt5_1_migration_prompt: Option, /// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4e41a2a0d6..215a0caedc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1532,6 +1532,9 @@ impl App { AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } + AppEvent::OpenPersonalityPopup => { + self.chat_widget.open_personality_popup(); + } AppEvent::OpenAllModelsPopup { models } => { self.chat_widget.open_all_models_popup(models); } @@ -1976,6 +1979,9 @@ impl App { AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } + AppEvent::UpdatePersonalityNudgeHidden(hidden) => { + self.chat_widget.set_personality_nudge_hidden(hidden); + } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) .set_hide_full_access_warning(true) @@ -2021,6 +2027,21 @@ impl App { )); } } + AppEvent::PersistPersonalityNudgeHidden => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_personality_nudge(true) + .apply() + .await + { + tracing::error!( + error = %err, + "failed to persist personality nudge preference" + ); + self.chat_widget.add_error_message(format!( + "Failed to save personality nudge preference: {err}" + )); + } + } AppEvent::PersistModelMigrationPromptAcknowledged { from_model, to_model, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b65d1efb62..da31a43fa8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -126,6 +126,9 @@ pub(crate) enum AppEvent { model: ModelPreset, }, + /// Open the personality selection popup. + OpenPersonalityPopup, + /// Open the full model picker (non-auto models). OpenAllModelsPopup { models: Vec, @@ -202,6 +205,9 @@ pub(crate) enum AppEvent { /// Update whether the rate limit switch prompt has been acknowledged for the session. UpdateRateLimitSwitchPromptHidden(bool), + /// Update whether the personality nudge has been acknowledged for the session. + UpdatePersonalityNudgeHidden(bool), + /// Persist the acknowledgement flag for the full access warning prompt. PersistFullAccessWarningAcknowledged, @@ -212,6 +218,9 @@ pub(crate) enum AppEvent { /// Persist the acknowledgement flag for the rate limit switch prompt. PersistRateLimitSwitchPromptHidden, + /// Persist the acknowledgement flag for the personality nudge. + PersistPersonalityNudgeHidden, + /// Persist the acknowledgement flag for the model migration prompt. PersistModelMigrationPromptAcknowledged { from_model: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd1014b2f4..9ce352c2b0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -386,6 +386,14 @@ enum RateLimitSwitchPromptState { Shown, } +#[derive(Default)] +enum PersonalityNudgeState { + #[default] + Idle, + Pending, + Shown, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -438,6 +446,7 @@ pub(crate) struct ChatWidget { plan_type: Option, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, + personality_nudge: PersonalityNudgeState, rate_limit_poller: Option>, // Stream lifecycle controller stream_controller: Option, @@ -745,6 +754,7 @@ impl ChatWidget { ); self.refresh_model_display(); self.sync_personality_command_enabled(); + self.schedule_personality_nudge_if_needed(); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -904,7 +914,7 @@ impl ChatWidget { response: last_agent_message.unwrap_or_default(), }); - self.maybe_show_pending_rate_limit_prompt(); + self.maybe_show_post_turn_nudges(); } fn maybe_prompt_plan_implementation(&mut self, last_agent_message: Option<&str>) { @@ -1109,7 +1119,7 @@ impl ChatWidget { self.unified_exec_wait_streak = None; self.clear_unified_exec_processes(); self.stream_controller = None; - self.maybe_show_pending_rate_limit_prompt(); + self.maybe_show_post_turn_nudges(); } fn on_error(&mut self, message: String) { @@ -2018,6 +2028,7 @@ impl ChatWidget { plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + personality_nudge: PersonalityNudgeState::default(), rate_limit_poller: None, stream_controller: None, running_commands: HashMap::new(), @@ -2151,6 +2162,7 @@ impl ChatWidget { plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + personality_nudge: PersonalityNudgeState::default(), rate_limit_poller: None, stream_controller: None, running_commands: HashMap::new(), @@ -2277,6 +2289,7 @@ impl ChatWidget { plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + personality_nudge: PersonalityNudgeState::default(), rate_limit_poller: None, stream_controller: None, running_commands: HashMap::new(), @@ -3331,6 +3344,23 @@ impl ChatWidget { .unwrap_or(false) } + fn maybe_show_post_turn_nudges(&mut self) { + let rate_limit_was_pending = matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ); + self.maybe_show_pending_rate_limit_prompt(); + if rate_limit_was_pending + && matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + ) + { + return; + } + self.maybe_show_pending_personality_nudge(); + } + fn maybe_show_pending_rate_limit_prompt(&mut self) { if self.rate_limit_switch_prompt_hidden() { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; @@ -3423,6 +3453,78 @@ impl ChatWidget { }); } + fn personality_nudge_hidden(&self) -> bool { + self.config.notices.hide_personality_nudge.unwrap_or(false) + } + + fn schedule_personality_nudge_if_needed(&mut self) { + if !self.is_session_configured() { + return; + } + if self.personality_nudge_hidden() { + self.personality_nudge = PersonalityNudgeState::Idle; + return; + } + if self.config.model_personality.is_some() || !self.current_model_supports_personality() { + self.personality_nudge = PersonalityNudgeState::Idle; + return; + } + if matches!(self.personality_nudge, PersonalityNudgeState::Shown) { + return; + } + self.personality_nudge = PersonalityNudgeState::Pending; + } + + fn maybe_show_pending_personality_nudge(&mut self) { + if self.personality_nudge_hidden() { + self.personality_nudge = PersonalityNudgeState::Idle; + return; + } + if !matches!(self.personality_nudge, PersonalityNudgeState::Pending) { + return; + } + if self.config.model_personality.is_some() || !self.current_model_supports_personality() { + self.personality_nudge = PersonalityNudgeState::Idle; + return; + } + + self.open_personality_nudge(); + self.personality_nudge = PersonalityNudgeState::Shown; + self.app_event_tx + .send(AppEvent::UpdatePersonalityNudgeHidden(true)); + self.app_event_tx + .send(AppEvent::PersistPersonalityNudgeHidden); + } + + fn open_personality_nudge(&mut self) { + let choose_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenPersonalityPopup); + })]; + let items = vec![ + SelectionItem { + name: "Choose a personality".to_string(), + description: Some("Pick Friendly or Pragmatic for future responses.".to_string()), + actions: choose_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Not now".to_string(), + description: Some("You can run /personality any time.".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("New: response personalities".to_string()), + subtitle: Some("Prefer a different style? Try /personality.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + /// Open a popup to choose a quick auto model. Selecting "All models" /// opens the full picker with every available preset. pub(crate) fn open_model_popup(&mut self) { @@ -4744,6 +4846,13 @@ impl ChatWidget { } } + pub(crate) fn set_personality_nudge_hidden(&mut self, hidden: bool) { + self.config.notices.hide_personality_nudge = Some(hidden); + if hidden { + self.personality_nudge = PersonalityNudgeState::Idle; + } + } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn world_writable_warning_hidden(&self) -> bool { self.config @@ -4767,6 +4876,7 @@ impl ChatWidget { /// Set the personality in the widget's config copy. pub(crate) fn set_personality(&mut self, personality: Personality) { self.config.model_personality = Some(personality); + self.personality_nudge = PersonalityNudgeState::Idle; } /// Set the model in the widget's config copy and stored collaboration mode. @@ -4781,6 +4891,7 @@ impl ChatWidget { } self.refresh_model_display(); self.sync_personality_command_enabled(); + self.schedule_personality_nudge_if_needed(); } pub(crate) fn current_model(&self) -> &str { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_nudge_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_nudge_popup.snap new file mode 100644 index 0000000000..cb55673536 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_nudge_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + New: response personalities + Prefer a different style? Try /personality. + +› 1. Choose a personality Pick Friendly or Pragmatic for future responses. + 2. Not now You can run /personality any time. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 76d5600010..16847892bc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -798,6 +798,7 @@ async fn make_chatwidget_manual( plan_type: None, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + personality_nudge: PersonalityNudgeState::default(), rate_limit_poller: None, stream_controller: None, running_commands: HashMap::new(), @@ -1126,6 +1127,78 @@ async fn rate_limit_switch_prompt_respects_hidden_notice() { )); } +fn session_configured_event_for(model: &str) -> Event { + let rollout_file = NamedTempFile::new().expect("rollout file"); + Event { + id: "session-configured".into(), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + model: model.to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }), + } +} + +#[tokio::test] +async fn personality_nudge_respects_hidden_notice() { + let (mut chat, _rx, _) = make_chatwidget_manual(Some("bengalfox")).await; + chat.config.notices.hide_personality_nudge = Some(true); + + chat.handle_codex_event(session_configured_event_for("bengalfox")); + chat.maybe_show_post_turn_nudges(); + + assert!(matches!( + chat.personality_nudge, + PersonalityNudgeState::Idle + )); +} + +#[tokio::test] +async fn personality_nudge_shows_once_and_hides_after_seen() { + let (mut chat, mut rx, _) = make_chatwidget_manual(Some("bengalfox")).await; + + chat.handle_codex_event(session_configured_event_for("bengalfox")); + assert!(matches!( + chat.personality_nudge, + PersonalityNudgeState::Pending + )); + + chat.maybe_show_post_turn_nudges(); + assert!(matches!( + chat.personality_nudge, + PersonalityNudgeState::Shown + )); + + let mut saw_update = false; + let mut saw_persist = false; + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::UpdatePersonalityNudgeHidden(true) => saw_update = true, + AppEvent::PersistPersonalityNudgeHidden => saw_persist = true, + _ => {} + } + } + assert!(saw_update, "expected UpdatePersonalityNudgeHidden(true)"); + assert!(saw_persist, "expected PersistPersonalityNudgeHidden"); + + chat.set_personality_nudge_hidden(true); + chat.schedule_personality_nudge_if_needed(); + + assert!(matches!( + chat.personality_nudge, + PersonalityNudgeState::Idle + )); +} + #[tokio::test] async fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); @@ -2981,6 +3054,16 @@ async fn personality_selection_popup_snapshot() { assert_snapshot!("personality_selection_popup", popup); } +#[tokio::test] +async fn personality_nudge_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("bengalfox")).await; + chat.handle_codex_event(session_configured_event_for("bengalfox")); + chat.maybe_show_post_turn_nudges(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("personality_nudge_popup", popup); +} + #[tokio::test] async fn model_picker_hides_show_in_picker_false_models_from_cache() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;