Notify TUI about plan mode prompts and user input requests (#13495)

Addresses #13478

Summary
- Add two new scopes for `tui.notifications` config: `plan-mode-prompt`
and `user-input-requested`.
- Add Plan Mode prompt and user-input-requested notifications to the TUI
so these events surface consistently outside of plan mode
- Add helpers and tests to ensure the new notification types publish the
right titles, summaries, and type tags for filtering
- Add prioritization mechanism to fix an existing bug where one
notification event could arbitrarily overwrite others

Testing
- Manually tested plan mode to ensure that notification appeared
This commit is contained in:
Eric Traut
2026-03-04 15:08:57 -07:00
committed by GitHub
Unverified
parent ce139bb1af
commit f80e5d979d
2 changed files with 223 additions and 4 deletions
+76 -4
View File
@@ -1656,6 +1656,9 @@ impl ChatWidget {
items,
..Default::default()
});
self.notify(Notification::PlanModePrompt {
title: PLAN_IMPLEMENTATION_TITLE.to_string(),
});
}
pub(crate) fn open_multi_agent_enable_prompt(&mut self) {
@@ -2872,6 +2875,10 @@ impl ChatWidget {
pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) {
self.flush_answer_stream_with_separator();
self.notify(Notification::UserInputRequested {
question_count: ev.questions.len(),
summary: Notification::user_input_request_summary(&ev.questions),
});
self.bottom_pane.push_user_input_request(ev);
self.request_redraw();
}
@@ -4976,6 +4983,11 @@ impl ChatWidget {
if !notification.allowed_for(&self.config.tui_notifications) {
return;
}
if let Some(existing) = self.pending_notification.as_ref()
&& existing.priority() > notification.priority()
{
return;
}
self.pending_notification = Some(notification);
self.request_redraw();
}
@@ -6172,6 +6184,9 @@ impl ChatWidget {
],
..Default::default()
});
self.notify(Notification::PlanModePrompt {
title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(),
});
}
/// Open a popup to choose the reasoning effort (stage 2) for the given model.
@@ -8383,11 +8398,28 @@ impl Renderable for ChatWidget {
}
}
#[derive(Debug)]
enum Notification {
AgentTurnComplete { response: String },
ExecApprovalRequested { command: String },
EditApprovalRequested { cwd: PathBuf, changes: Vec<PathBuf> },
ElicitationRequested { server_name: String },
AgentTurnComplete {
response: String,
},
ExecApprovalRequested {
command: String,
},
EditApprovalRequested {
cwd: PathBuf,
changes: Vec<PathBuf>,
},
ElicitationRequested {
server_name: String,
},
PlanModePrompt {
title: String,
},
UserInputRequested {
question_count: usize,
summary: Option<String>,
},
}
impl Notification {
@@ -8414,6 +8446,17 @@ impl Notification {
Notification::ElicitationRequested { server_name } => {
format!("Approval requested by {server_name}")
}
Notification::PlanModePrompt { title } => {
format!("Plan mode prompt: {title}")
}
Notification::UserInputRequested {
question_count,
summary,
} => match (*question_count, summary.as_deref()) {
(1, Some(summary)) => format!("Question requested: {summary}"),
(1, None) => "Question requested".to_string(),
(count, _) => format!("Questions requested: {count}"),
},
}
}
@@ -8423,6 +8466,19 @@ impl Notification {
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. } => "approval-requested",
Notification::PlanModePrompt { .. } => "plan-mode-prompt",
Notification::UserInputRequested { .. } => "user-input-requested",
}
}
fn priority(&self) -> u8 {
match self {
Notification::AgentTurnComplete { .. } => 0,
Notification::ExecApprovalRequested { .. }
| Notification::EditApprovalRequested { .. }
| Notification::ElicitationRequested { .. }
| Notification::PlanModePrompt { .. }
| Notification::UserInputRequested { .. } => 1,
}
}
@@ -8448,6 +8504,22 @@ impl Notification {
Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES))
}
}
fn user_input_request_summary(
questions: &[codex_protocol::request_user_input::RequestUserInputQuestion],
) -> Option<String> {
let first_question = questions.first()?;
let summary = if first_question.header.trim().is_empty() {
first_question.question.trim()
} else {
first_question.header.trim()
};
if summary.is_empty() {
None
} else {
Some(truncate_text(summary, 30))
}
}
}
const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200;
+147
View File
@@ -22,6 +22,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::Constrained;
use codex_core::config::ConstraintError;
use codex_core::config::types::Notifications;
#[cfg(target_os = "windows")]
use codex_core::config::types::WindowsSandboxModeToml;
use codex_core::config_loader::RequirementSource;
@@ -96,6 +97,9 @@ use codex_protocol::protocol::UndoCompletedEvent;
use codex_protocol::protocol::UndoStartedEvent;
use codex_protocol::protocol::ViewImageToolCallEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -2535,6 +2539,149 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override(
);
}
#[test]
fn plan_mode_prompt_notification_uses_dedicated_type_name() {
let notification = Notification::PlanModePrompt {
title: PLAN_IMPLEMENTATION_TITLE.to_string(),
};
assert!(notification.allowed_for(&Notifications::Custom(
vec!["plan-mode-prompt".to_string(),]
)));
assert!(!notification.allowed_for(&Notifications::Custom(vec![
"approval-requested".to_string(),
])));
assert_eq!(
notification.display(),
format!("Plan mode prompt: {PLAN_IMPLEMENTATION_TITLE}")
);
}
#[test]
fn user_input_requested_notification_uses_dedicated_type_name() {
let notification = Notification::UserInputRequested {
question_count: 1,
summary: Some("Reasoning scope".to_string()),
};
assert!(notification.allowed_for(&Notifications::Custom(vec![
"user-input-requested".to_string(),
])));
assert!(!notification.allowed_for(&Notifications::Custom(vec![
"approval-requested".to_string(),
])));
assert_eq!(
notification.display(),
"Question requested: Reasoning scope"
);
}
#[tokio::test]
async fn open_plan_implementation_prompt_sets_pending_notification() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.config.tui_notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]);
chat.open_plan_implementation_prompt();
assert_matches!(
chat.pending_notification,
Some(Notification::PlanModePrompt { ref title }) if title == PLAN_IMPLEMENTATION_TITLE
);
}
#[tokio::test]
async fn open_plan_reasoning_scope_prompt_sets_pending_notification() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.config.tui_notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]);
chat.open_plan_reasoning_scope_prompt(
"gpt-5.1-codex-max".to_string(),
Some(ReasoningEffortConfig::High),
);
assert_matches!(
chat.pending_notification,
Some(Notification::PlanModePrompt { ref title }) if title == PLAN_MODE_REASONING_SCOPE_TITLE
);
}
#[tokio::test]
async fn agent_turn_complete_does_not_override_pending_plan_mode_prompt_notification() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.open_plan_implementation_prompt();
chat.notify(Notification::AgentTurnComplete {
response: "done".to_string(),
});
assert_matches!(
chat.pending_notification,
Some(Notification::PlanModePrompt { ref title }) if title == PLAN_IMPLEMENTATION_TITLE
);
}
#[tokio::test]
async fn user_input_notification_overrides_pending_agent_turn_complete_notification() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.notify(Notification::AgentTurnComplete {
response: "done".to_string(),
});
chat.handle_request_user_input_now(RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
id: "reasoning_scope".to_string(),
header: "Reasoning scope".to_string(),
question: "Which reasoning scope should I use?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![RequestUserInputQuestionOption {
label: "Plan only".to_string(),
description: "Update only Plan mode.".to_string(),
}]),
}],
});
assert_matches!(
chat.pending_notification,
Some(Notification::UserInputRequested {
question_count: 1,
summary: Some(ref summary),
}) if summary == "Reasoning scope"
);
}
#[tokio::test]
async fn handle_request_user_input_sets_pending_notification() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
chat.config.tui_notifications = Notifications::Custom(vec!["user-input-requested".to_string()]);
chat.handle_request_user_input_now(RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
id: "reasoning_scope".to_string(),
header: "Reasoning scope".to_string(),
question: "Which reasoning scope should I use?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![RequestUserInputQuestionOption {
label: "Plan only".to_string(),
description: "Update only Plan mode.".to_string(),
}]),
}],
});
assert_matches!(
chat.pending_notification,
Some(Notification::UserInputRequested {
question_count: 1,
summary: Some(ref summary),
}) if summary == "Reasoning scope"
);
}
#[tokio::test]
async fn plan_reasoning_scope_popup_mentions_selected_reasoning() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;