tui: add one-time personality nudge NUX with persisted hide flag

This commit is contained in:
Charles Cunningham
2026-01-26 12:52:13 -08:00
parent c7c2b3cf8d
commit d4e9556a67
8 changed files with 284 additions and 2 deletions

View File

@@ -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"

View File

@@ -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");

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;