diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0d1395d51d..96304dd7ea 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1504,6 +1504,10 @@ impl ChatComposer { self.is_task_running = running; } + pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { + self.placeholder_text = placeholder; + } + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent != percent { self.context_window_percent = percent; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 690c5dac27..423c41a905 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -274,6 +274,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_placeholder_text(&mut self, text: String) { + self.composer.set_placeholder_text(text); + self.request_redraw(); + } + /// Get the current composer text (for tests and programmatic checks). pub(crate) fn composer_text(&self) -> String { self.composer.current_text() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0f388a6a98..5af9c699cd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -92,10 +92,12 @@ use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::markdown::append_markdown; +use crate::security_review::SECURITY_REVIEW_FOLLOW_UP_MARKER; use crate::security_review::SecurityReviewFailure; use crate::security_review::SecurityReviewMode; use crate::security_review::SecurityReviewRequest; use crate::security_review::SecurityReviewResult; +use crate::security_review::build_follow_up_user_prompt; use crate::security_review::run_security_review; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; @@ -269,6 +271,7 @@ pub(crate) struct ChatWidget { retry_status_header: Option, conversation_id: Option, frame_requester: FrameRequester, + default_placeholder: String, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, // When resuming an existing session (selected via resume picker), avoid an @@ -289,6 +292,7 @@ pub(crate) struct ChatWidget { security_review_task: Option>, security_review_context: Option, security_review_artifacts: Option, + security_review_follow_up: Option, last_rendered_width: std::cell::Cell>, } @@ -345,6 +349,14 @@ struct SecurityReviewContext { last_log: Option, } +struct SecurityReviewFollowUpState { + repo_root: PathBuf, + scope_paths: Vec, + mode: SecurityReviewMode, + follow_up_path: PathBuf, + follow_up_label: String, +} + #[allow(dead_code)] struct SecurityReviewArtifactsState { repo_root: PathBuf, @@ -1101,6 +1113,7 @@ impl ChatWidget { } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let default_placeholder = placeholder.clone(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); Self { @@ -1149,6 +1162,8 @@ impl ChatWidget { security_review_task: None, security_review_context: None, security_review_artifacts: None, + security_review_follow_up: None, + default_placeholder, last_rendered_width: std::cell::Cell::new(None), } } @@ -1170,6 +1185,7 @@ impl ChatWidget { } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let default_placeholder = placeholder.clone(); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); @@ -1220,6 +1236,8 @@ impl ChatWidget { security_review_task: None, security_review_context: None, security_review_artifacts: None, + security_review_follow_up: None, + default_placeholder, last_rendered_width: std::cell::Cell::new(None), } } @@ -1479,7 +1497,10 @@ impl ChatWidget { let mut items: Vec = Vec::new(); if !text.is_empty() { - items.push(InputItem::Text { text: text.clone() }); + let outbound = self + .security_review_follow_up_prompt(&text) + .unwrap_or_else(|| text.clone()); + items.push(InputItem::Text { text: outbound }); } for path in image_paths { @@ -2054,6 +2075,32 @@ impl ChatWidget { self.config.model = model.to_string(); } + fn clear_security_review_follow_up(&mut self) { + if self.security_review_follow_up.take().is_some() { + self.bottom_pane + .set_placeholder_text(self.default_placeholder.clone()); + } + } + + fn security_review_follow_up_prompt(&self, text: &str) -> Option { + let state = self.security_review_follow_up.as_ref()?; + if text.starts_with(SECURITY_REVIEW_FOLLOW_UP_MARKER) { + return None; + } + let question = text.trim(); + if question.is_empty() { + return None; + } + Some(build_follow_up_user_prompt( + state.mode, + &state.scope_paths, + state.follow_up_path.as_path(), + &state.repo_root, + state.follow_up_label.as_str(), + question, + )) + } + pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { self.add_to_history(history_cell::new_info_event(message, hint)); self.request_redraw(); @@ -2101,7 +2148,7 @@ impl ChatWidget { self.bottom_pane .update_status_header(String::from("Working")); self.security_review_context = None; - self.security_review_artifacts = None; + self.clear_security_review_follow_up(); self.add_info_message("Security review cancelled.".to_string(), None); return true; } @@ -2297,7 +2344,7 @@ impl ChatWidget { return; } - self.security_review_artifacts = None; + self.clear_security_review_follow_up(); let repo_path = self.config.cwd.clone(); if !repo_path.exists() { @@ -2666,11 +2713,43 @@ impl ChatWidget { } self.security_review_artifacts = Some(SecurityReviewArtifactsState { - repo_root: repo_path, + repo_root: repo_path.clone(), snapshot_path: result.snapshot_path.clone(), bugs_path: result.bugs_path.clone(), report_path: result.report_path.clone(), - report_html_path: result.report_html_path, + report_html_path: result.report_html_path.clone(), + }); + + let follow_up_path = match mode { + SecurityReviewMode::Full => result + .report_path + .clone() + .or_else(|| result.report_html_path.clone()) + .unwrap_or_else(|| result.bugs_path.clone()), + SecurityReviewMode::Bugs => result.bugs_path.clone(), + }; + let has_report = result.report_path.is_some() || result.report_html_path.is_some(); + let follow_up_label = if mode == SecurityReviewMode::Full && has_report { + "Report".to_string() + } else { + "Bugs".to_string() + }; + let follow_up_display = display_path_for(&follow_up_path, &repo_path); + self.add_info_message( + format!( + "Security review follow-up ready — questions will include context from {follow_up_label} ({follow_up_display})." + ), + None, + ); + self.bottom_pane + .set_placeholder_text("Ask a security review follow-up question".to_string()); + + self.security_review_follow_up = Some(SecurityReviewFollowUpState { + repo_root: repo_path, + scope_paths, + mode, + follow_up_path, + follow_up_label, }); } @@ -2680,6 +2759,7 @@ impl ChatWidget { .update_status_header(String::from("Working")); self.security_review_task = None; self.security_review_context = None; + self.clear_security_review_follow_up(); self.add_error_message(format!("Security review failed: {}", error.message)); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index eee39d20f9..9bbfe4968d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -249,12 +249,13 @@ fn make_chatwidget_manual() -> ( let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let cfg = test_config(); + let placeholder = "Ask Codex to do anything".to_string(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), + placeholder_text: placeholder.clone(), disable_paste_burst: false, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); @@ -283,6 +284,7 @@ fn make_chatwidget_manual() -> ( retry_status_header: None, conversation_id: None, frame_requester: FrameRequester::test_dummy(), + default_placeholder: placeholder, show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, @@ -294,6 +296,7 @@ fn make_chatwidget_manual() -> ( security_review_task: None, security_review_context: None, security_review_artifacts: None, + security_review_follow_up: None, last_rendered_width: std::cell::Cell::new(None), }; (widget, rx, op_rx)