mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
ce139bb1af
commit
f80e5d979d
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user