mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
tui: add one-time personality nudge NUX with persisted hide flag
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -480,6 +480,8 @@ pub struct Notice {
|
||||
pub hide_world_writable_warning: Option<bool>,
|
||||
/// Tracks whether the user opted out of the rate limit model switch reminder.
|
||||
pub hide_rate_limit_model_nudge: Option<bool>,
|
||||
/// Tracks whether the user has already seen the personality selection nudge.
|
||||
pub hide_personality_nudge: Option<bool>,
|
||||
/// Tracks whether the user has seen the model migration prompt
|
||||
pub hide_gpt5_1_migration_prompt: Option<bool>,
|
||||
/// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ModelPreset>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PlanType>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
personality_nudge: PersonalityNudgeState,
|
||||
rate_limit_poller: Option<JoinHandle<()>>,
|
||||
// Stream lifecycle controller
|
||||
stream_controller: Option<StreamController>,
|
||||
@@ -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<SelectionAction> = 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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user