diff --git a/codex-rs/tui/src/chatwidget/notifications.rs b/codex-rs/tui/src/chatwidget/notifications.rs index 75506213bc..2668e962a4 100644 --- a/codex-rs/tui/src/chatwidget/notifications.rs +++ b/codex-rs/tui/src/chatwidget/notifications.rs @@ -30,6 +30,7 @@ pub(super) enum Notification { EditApprovalRequested { cwd: PathBuf, changes: Vec }, ElicitationRequested { server_name: String }, PlanModePrompt { title: String }, + AsyncQuestion { title: String }, } impl Notification { @@ -62,6 +63,9 @@ impl Notification { Notification::PlanModePrompt { title } => { format!("Plan mode prompt: {title}") } + Notification::AsyncQuestion { title } => { + format!("Question: {title}") + } } } @@ -72,6 +76,7 @@ impl Notification { | Notification::EditApprovalRequested { .. } | Notification::ElicitationRequested { .. } => "approval-requested", Notification::PlanModePrompt { .. } => "plan-mode-prompt", + Notification::AsyncQuestion { .. } => "async-question", } } @@ -81,7 +86,8 @@ impl Notification { Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } | Notification::ElicitationRequested { .. } - | Notification::PlanModePrompt { .. } => 1, + | Notification::PlanModePrompt { .. } + | Notification::AsyncQuestion { .. } => 1, } } diff --git a/codex-rs/tui/src/chatwidget/questions.rs b/codex-rs/tui/src/chatwidget/questions.rs index 5313d25372..682ccf9423 100644 --- a/codex-rs/tui/src/chatwidget/questions.rs +++ b/codex-rs/tui/src/chatwidget/questions.rs @@ -11,7 +11,19 @@ impl ChatWidget { message_id: &str, questions: &[AsyncUserInputQuestion], ) { + let previous_count = self.bottom_pane.question_editor().unanswered_count(); self.bottom_pane.push_async_questions(message_id, questions); + let added_count = self.bottom_pane.question_editor().unanswered_count() - previous_count; + if added_count > 0 { + let title = match questions { + [question] if !question.title.trim().is_empty() => { + truncate_text(question.title.trim(), /*max_graphemes*/ 30) + } + _ if added_count == 1 => "Question requested".to_string(), + _ => format!("{added_count} questions requested"), + }; + self.notify(Notification::AsyncQuestion { title }); + } self.refresh_pending_input_preview(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cb418f967d..0cb92544e6 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -297,3 +297,6 @@ pub(super) use helpers::*; #[path = "tests/questions_tests.rs"] mod questions_tests; + +#[path = "tests/question_notifications_tests.rs"] +mod question_notifications_tests; diff --git a/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs b/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs new file mode 100644 index 0000000000..a47708f382 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/question_notifications_tests.rs @@ -0,0 +1,100 @@ +//! Async questions notify once on arrival and honor the terminal notification settings. + +use super::*; +use codex_protocol::items::AsyncUserInputQuestion; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn live_async_question_notifies_once_and_takes_priority_over_turn_completion() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + let item = AppServerThreadItem::AgentMessage { + id: "question".into(), + text: String::new(), + phase: None, + memory_citation: None, + delivery: None, + questions: Some(vec![AsyncUserInputQuestion { + title: "Which environment?".into(), + options: None, + }]), + }; + for kind in [ + ReplayKind::ResumeInitialMessages, + ReplayKind::ThreadSnapshot, + ] { + chat.replay_thread_item(item.clone(), "turn".into(), kind); + assert!(chat.pending_notification.is_none()); + } + + let notification = ServerNotification::ItemCompleted(ItemCompletedNotification { + item, + thread_id: "thread".into(), + turn_id: "turn".into(), + completed_at_ms: 0, + }); + chat.handle_server_notification(notification.clone(), /*replay_kind*/ None); + chat.notify(Notification::AgentTurnComplete { + response: "Done".into(), + }); + insta::assert_snapshot!( + chat.pending_notification.take().unwrap().display(), + @"Question: Which environment?" + ); + + chat.handle_server_notification(notification.clone(), /*replay_kind*/ None); + assert!(chat.pending_notification.is_none()); + chat.bottom_pane.clear_pending_questions(); + chat.handle_server_notification(notification, /*replay_kind*/ None); + assert!(chat.pending_notification.is_none()); +} + +#[tokio::test] +async fn async_question_notifications_respect_settings_and_ignore_empty_batches() { + for (settings, expected) in [ + (Notifications::Enabled(true), true), + (Notifications::Enabled(false), false), + (Notifications::Custom(vec!["async-question".into()]), true), + ( + Notifications::Custom(vec!["agent-turn-complete".into()]), + false, + ), + ] { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.local_settings.tui.notification_settings.notifications = settings; + chat.add_async_questions("empty", &[]); + assert!(chat.pending_notification.is_none()); + chat.add_async_questions( + "question", + &[AsyncUserInputQuestion { + title: "Which way?".into(), + options: None, + }], + ); + assert_eq!(chat.pending_notification.is_some(), expected); + } +} + +#[tokio::test] +async fn async_question_notification_summarizes_batches_and_bounds_long_titles() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + let questions = vec![ + AsyncUserInputQuestion { + title: "Which environment should we deploy this change to?".into(), + options: None, + }, + AsyncUserInputQuestion { + title: "Any details?".into(), + options: None, + }, + ]; + chat.add_async_questions("batch", &questions); + insta::assert_snapshot!( + chat.pending_notification.take().unwrap().display(), + @"Question: 2 questions requested" + ); + chat.add_async_questions("single", &questions[..1]); + insta::assert_snapshot!( + chat.pending_notification.take().unwrap().display(), + @"Question: Which environment should we..." + ); +}