use std::collections::HashMap; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; use codex_core::protocol::AgentReasoningEvent; use codex_core::protocol::AgentReasoningRawContentDeltaEvent; use codex_core::protocol::AgentReasoningRawContentEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::BackgroundEventEvent; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use rand::Rng; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; #[cfg(target_os = "windows")] use crate::onboarding::WSL_INSTRUCTIONS; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; use self::interrupts::InterruptManager; mod agent; use self::agent::spawn_agent; use self::agent::spawn_agent_from_existing; mod session_header; use self::session_header::SessionHeader; use crate::streaming::controller::StreamController; use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; use codex_common::model_presets::ModelPreset; use codex_common::model_presets::builtin_model_presets; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; // Track information about an in-flight exec command. struct RunningCommand { command: Vec, parsed_cmd: Vec, is_user_shell_command: bool, } const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; #[derive(Default)] struct RateLimitWarningState { secondary_index: usize, primary_index: usize, } impl RateLimitWarningState { fn take_warnings( &mut self, secondary_used_percent: Option, secondary_window_minutes: Option, primary_used_percent: Option, primary_window_minutes: Option, ) -> Vec { let reached_secondary_cap = matches!(secondary_used_percent, Some(percent) if percent == 100.0); let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); if reached_secondary_cap || reached_primary_cap { return Vec::new(); } let mut warnings = Vec::new(); if let Some(secondary_used_percent) = secondary_used_percent { let mut highest_secondary: Option = None; while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] { highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); self.secondary_index += 1; } if let Some(threshold) = highest_secondary { let limit_label = secondary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "weekly".to_string()); warnings.push(format!( "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." )); } } if let Some(primary_used_percent) = primary_used_percent { let mut highest_primary: Option = None; while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] { highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); self.primary_index += 1; } if let Some(threshold) = highest_primary { let limit_label = primary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "5h".to_string()); warnings.push(format!( "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." )); } } warnings } } pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { const MINUTES_PER_HOUR: i64 = 60; const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; const ROUNDING_BIAS_MINUTES: i64 = 3; let windows_minutes = windows_minutes.max(0); if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); format!("{hours}h") } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { "weekly".to_string() } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { "monthly".to_string() } else { "annual".to_string() } } /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, pub(crate) initial_prompt: Option, pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, } pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane, active_cell: Option>, config: Config, auth_manager: Arc, session_header: SessionHeader, initial_user_message: Option, token_info: Option, rate_limit_snapshot: Option, rate_limit_warnings: RateLimitWarningState, // Stream lifecycle controller stream_controller: Option, running_commands: HashMap, task_complete_pending: bool, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, // Current status header shown in the status indicator. current_status_header: String, // Previous status header to restore after a transient stream retry. retry_status_header: Option, conversation_id: Option, frame_requester: FrameRequester, // 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 // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, // Pending notification to show when unfocused on next Draw pending_notification: Option, // Simple review mode flag; used to adjust layout and banners. is_review_mode: bool, // Whether to add a final message separator after the last message needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback feedback: codex_feedback::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, } struct UserMessage { text: String, image_paths: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, image_paths: Vec::new(), } } } fn create_initial_user_message(text: String, image_paths: Vec) -> Option { if text.is_empty() && image_paths.is_empty() { None } else { Some(UserMessage { text, image_paths }) } } impl ChatWidget { fn flush_answer_stream_with_separator(&mut self) { if let Some(mut controller) = self.stream_controller.take() && let Some(cell) = controller.finalize() { self.add_boxed_history(cell); } } fn set_status_header(&mut self, header: String) { self.current_status_header = header.clone(); self.bottom_pane.update_status_header(header); } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, event, self.show_welcome_banner, )); if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } if !self.suppress_session_configured_redraw { self.request_redraw(); } } pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, include_logs: bool, ) { // Build a fresh snapshot at the time of opening the note overlay. let snapshot = self.feedback.snapshot(self.conversation_id); let rollout = if include_logs { self.current_rollout_path.clone() } else { None }; let view = crate::bottom_pane::FeedbackNoteView::new( category, snapshot, rollout, self.app_event_tx.clone(), include_logs, ); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), ); self.bottom_pane.show_selection_view(params); self.request_redraw(); } fn on_agent_message(&mut self, message: String) { // If we have a stream_controller, then the final agent message is redundant and will be a // duplicate of what has already been streamed. if self.stream_controller.is_none() { self.handle_streaming_delta(message); } self.flush_answer_stream_with_separator(); self.handle_stream_finished(); self.request_redraw(); } fn on_agent_message_delta(&mut self, delta: String) { self.handle_streaming_delta(delta); } fn on_agent_reasoning_delta(&mut self, delta: String) { // For reasoning deltas, do not stream to history. Accumulate the // current reasoning block and extract the first bold element // (between **/**) as the chunk header. Show this header as status. self.reasoning_buffer.push_str(&delta); if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. } self.request_redraw(); } fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), &self.config, ); self.add_boxed_history(cell); } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.request_redraw(); } fn on_reasoning_section_break(&mut self) { // Start a new reasoning block for header extraction and accumulate transcript. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); self.full_reasoning_buffer.push_str("\n\n"); self.reasoning_buffer.clear(); } // Raw reasoning uses the same flow as summarized reasoning fn on_task_started(&mut self) { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.retry_status_header = None; self.bottom_pane.set_interrupt_hint_visible(true); self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); } fn on_task_complete(&mut self, last_agent_message: Option) { // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), }); } pub(crate) fn set_token_info(&mut self, info: Option) { if let Some(info) = info { let context_window = info .model_context_window .or(self.config.model_context_window); let percent = context_window.map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) }); self.bottom_pane.set_context_window_percent(percent); self.token_info = Some(info); } } fn on_rate_limit_snapshot(&mut self, snapshot: Option) { if let Some(snapshot) = snapshot { let warnings = self.rate_limit_warnings.take_warnings( snapshot .secondary .as_ref() .map(|window| window.used_percent), snapshot .secondary .as_ref() .and_then(|window| window.window_minutes), snapshot.primary.as_ref().map(|window| window.used_percent), snapshot .primary .as_ref() .and_then(|window| window.window_minutes), ); let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); self.rate_limit_snapshot = Some(display); if !warnings.is_empty() { for warning in warnings { self.add_to_history(history_cell::new_warning_event(warning)); } self.request_redraw(); } } else { self.rate_limit_snapshot = None; } } /// Finalize any active exec as failed and stop/clear running UI state. fn finalize_turn(&mut self) { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_cell_as_failed(); // Reset running state and clear streaming buffers. self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream_controller = None; } fn on_error(&mut self, message: String) { self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. self.maybe_send_next_queued_input(); } /// Handle a turn aborted due to user interrupt (Esc). /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { // Finalize, log a gentle prompt, and clear running state. self.finalize_turn(); if reason != TurnAbortReason::ReviewEnded { self.add_to_history(history_cell::new_error_event( "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), )); } // If any messages were queued during the task, restore them into the composer. if !self.queued_user_messages.is_empty() { let queued_text = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect::>() .join("\n"); let existing_text = self.bottom_pane.composer_text(); let combined = if existing_text.is_empty() { queued_text } else if queued_text.is_empty() { existing_text } else { format!("{queued_text}\n{existing_text}") }; self.bottom_pane.set_composer_text(combined); // Clear the queue and update the status indicator list. self.queued_user_messages.clear(); self.refresh_queued_user_messages(); } self.request_redraw(); } fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { let id2 = id.clone(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_exec_approval(id, ev), |s| s.handle_exec_approval_now(id2, ev2), ); } fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { let id2 = id.clone(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_apply_patch_approval(id, ev), |s| s.handle_apply_patch_approval_now(id2, ev2), ); } fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); } fn on_exec_command_output_delta( &mut self, _ev: codex_core::protocol::ExecCommandOutputDeltaEvent, ) { // TODO: Handle streaming exec output if/when implemented } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, )); } fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( event.path, &self.config.cwd, )); self.request_redraw(); } fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { let ev2 = event.clone(); self.defer_or_handle( |q| q.push_patch_end(event), |s| s.handle_patch_apply_end_now(ev2), ); } fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); } fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); } fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { let ev2 = ev.clone(); self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { self.flush_answer_stream_with_separator(); } fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_web_search_call(format!( "Searched: {}", ev.query ))); } fn on_get_history_entry_response( &mut self, event: codex_core::protocol::GetHistoryEntryResponseEvent, ) { let codex_core::protocol::GetHistoryEntryResponseEvent { offset, log_id, entry, } = event; self.bottom_pane .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); } fn on_shutdown_complete(&mut self) { self.app_event_tx.send(AppEvent::ExitRequest); } fn on_turn_diff(&mut self, unified_diff: String) { debug!("TurnDiffEvent: {unified_diff}"); } fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { let DeprecationNoticeEvent { summary, details } = event; self.add_to_history(history_cell::new_deprecation_notice(summary, details)); self.request_redraw(); } fn on_background_event(&mut self, message: String) { debug!("BackgroundEvent: {message}"); } fn on_undo_started(&mut self, event: UndoStartedEvent) { self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); let message = event .message .unwrap_or_else(|| "Undo in progress...".to_string()); self.set_status_header(message); } fn on_undo_completed(&mut self, event: UndoCompletedEvent) { let UndoCompletedEvent { success, message } = event; self.bottom_pane.hide_status_indicator(); let message = message.unwrap_or_else(|| { if success { "Undo completed successfully.".to_string() } else { "Undo failed.".to_string() } }); if success { self.add_info_message(message, None); } else { self.add_error_message(message); } } fn on_stream_error(&mut self, message: String) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status_header.clone()); } self.set_status_header(message); } /// Periodic tick to commit at most one queued line to history with a small delay, /// animating the output. pub(crate) fn on_commit_tick(&mut self) { if let Some(controller) = self.stream_controller.as_mut() { let (cell, is_idle) = controller.on_commit_tick(); if let Some(cell) = cell { self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } if is_idle { self.app_event_tx.send(AppEvent::StopCommitAnimation); } } } fn flush_interrupt_queue(&mut self) { let mut mgr = std::mem::take(&mut self.interrupts); mgr.flush_all(self); self.interrupts = mgr; } #[inline] fn defer_or_handle( &mut self, push: impl FnOnce(&mut InterruptManager), handle: impl FnOnce(&mut Self), ) { // Preserve deterministic FIFO across queued interrupts: once anything // is queued due to an active write cycle, continue queueing until the // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). if self.stream_controller.is_some() || !self.interrupts.is_empty() { push(&mut self.interrupts); } else { handle(self); } } fn handle_stream_finished(&mut self) { if self.task_complete_pending { self.bottom_pane.hide_status_indicator(); self.task_complete_pending = false; } // A completed stream indicates non-exec content was just inserted. self.flush_interrupt_queue(); } #[inline] fn handle_streaming_delta(&mut self, delta: String) { // Before streaming agent content, flush any active exec cell group. self.flush_active_cell(); if self.stream_controller.is_none() { if self.needs_final_message_separator { let elapsed_seconds = self .bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( self.last_rendered_width.get().map(|w| w.saturating_sub(2)), )); } if let Some(controller) = self.stream_controller.as_mut() && controller.push(&delta) { self.app_event_tx.send(AppEvent::StartCommitAnimation); } self.request_redraw(); } pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { let running = self.running_commands.remove(&ev.call_id); let (command, parsed, is_user_shell_command) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.is_user_shell_command), None => (vec![ev.call_id.clone()], Vec::new(), false), }; let needs_new = self .active_cell .as_ref() .map(|cell| cell.as_any().downcast_ref::().is_none()) .unwrap_or(true); if needs_new { self.flush_active_cell(); self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), command, parsed, is_user_shell_command, ))); } if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) { cell.complete_call( &ev.call_id, CommandOutput { exit_code: ev.exit_code, formatted_output: ev.formatted_output.clone(), aggregated_output: ev.aggregated_output.clone(), }, ev.duration, ); if cell.should_flush() { self.flush_active_cell(); } } } pub(crate) fn handle_patch_apply_end_now( &mut self, event: codex_core::protocol::PatchApplyEndEvent, ) { // If the patch was successful, just let the "Edited" block stand. // Otherwise, add a failure block. if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { self.flush_answer_stream_with_separator(); let command = shlex::try_join(ev.command.iter().map(String::as_str)) .unwrap_or_else(|_| ev.command.join(" ")); self.notify(Notification::ExecApprovalRequested { command }); let request = ApprovalRequest::Exec { id, command: ev.command, reason: ev.reason, risk: ev.risk, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); } pub(crate) fn handle_apply_patch_approval_now( &mut self, id: String, ev: ApplyPatchApprovalRequestEvent, ) { self.flush_answer_stream_with_separator(); let request = ApprovalRequest::ApplyPatch { id, reason: ev.reason, changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; self.bottom_pane.push_approval_request(request); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.clone(), changes: ev.changes.keys().cloned().collect(), }); } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( ev.call_id.clone(), RunningCommand { command: ev.command.clone(), parsed_cmd: ev.parsed_cmd.clone(), is_user_shell_command: ev.is_user_shell_command, }, ); if let Some(cell) = self .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) && let Some(new_exec) = cell.with_added_call( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd.clone(), ev.is_user_shell_command, ) { *cell = new_exec; } else { self.flush_active_cell(); self.active_cell = Some(Box::new(new_active_exec_command( ev.call_id.clone(), ev.command.clone(), ev.parsed_cmd, ev.is_user_shell_command, ))); } self.request_redraw(); } pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( ev.call_id, ev.invocation, ))); self.request_redraw(); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); let McpToolCallEndEvent { call_id, invocation, duration, result, } = ev; let extra_cell = match self .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) { Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), _ => { self.flush_active_cell(); let mut cell = history_cell::new_active_mcp_tool_call(call_id, invocation); let extra_cell = cell.complete(duration, result); self.active_cell = Some(Box::new(cell)); extra_cell } }; self.flush_active_cell(); if let Some(extra) = extra_cell { self.add_boxed_history(extra); } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height); let remaining = area.height.saturating_sub(bottom_min); let active_desired = self .active_cell .as_ref() .map_or(0, |c| c.desired_height(area.width) + 1); let active_height = active_desired.min(remaining); // Note: no header area; remaining is not used beyond computing active height. let header_height = 0u16; Layout::vertical([ Constraint::Length(header_height), Constraint::Length(active_height), Constraint::Min(bottom_min), ]) .areas(area) } pub(crate) fn new( common: ChatWidgetInit, conversation_manager: Arc, ) -> Self { let ChatWidgetInit { config, frame_requester, app_event_tx, initial_prompt, initial_images, enhanced_keys_supported, auth_manager, feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, }), active_cell: None, config: config.clone(), auth_manager, session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, ), token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, } } /// Create a ChatWidget attached to an existing conversation (e.g., a fork). pub(crate) fn new_from_existing( common: ChatWidgetInit, conversation: std::sync::Arc, session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { let ChatWidgetInit { config, frame_requester, app_event_tx, initial_prompt, initial_images, enhanced_keys_supported, auth_manager, feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, }), active_cell: None, config: config.clone(), auth_manager, session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, ), token_info: None, rate_limit_snapshot: None, rate_limit_warnings: RateLimitWarningState::default(), stream_controller: None, running_commands: HashMap::new(), task_complete_pending: false, interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: true, pending_notification: None, is_review_mode: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, } } pub fn desired_height(&self, width: u16) -> u16 { self.bottom_pane.desired_height(width) + self .active_cell .as_ref() .map_or(0, |c| c.desired_height(width) + 1) } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { self.on_ctrl_c(); return; } KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'v') => { if let Ok((path, info)) = paste_image_to_temp_png() { self.attach_image(path, info.width, info.height, info.encoded_format.label()); } return; } other if other.kind == KeyEventKind::Press => { self.bottom_pane.clear_ctrl_c_quit_hint(); } _ => {} } match key_event { KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::ALT, kind: KeyEventKind::Press, .. } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { self.bottom_pane.set_composer_text(user_message.text); self.refresh_queued_user_messages(); self.request_redraw(); } } _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { // If a task is running, queue the user input to be sent after the turn completes. let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), }; if self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); self.refresh_queued_user_messages(); } else { self.submit_user_message(user_message); } } InputResult::Command(cmd) => { self.dispatch_command(cmd); } InputResult::None => {} } } } } pub(crate) fn attach_image( &mut self, path: PathBuf, width: u32, height: u32, format_label: &str, ) { tracing::info!( "attach_image path={path:?} width={width} height={height} format={format_label}", ); self.bottom_pane .attach_image(path, width, height, format_label); self.request_redraw(); } fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() ); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); return; } match cmd { SlashCommand::Feedback => { // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); self.request_redraw(); } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { let message = format!( "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." ); self.add_info_message(message, None); return; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_text_message(INIT_PROMPT.to_string()); } SlashCommand::Compact => { self.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { self.open_review_popup(); } SlashCommand::Model => { self.open_model_popup(); } SlashCommand::Approvals => { self.open_approvals_popup(); } SlashCommand::Quit => { self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Logout => { if let Err(e) = codex_core::auth::logout( &self.config.codex_home, self.config.cli_auth_credentials_store_mode, ) { tracing::error!("failed to logout: {e}"); } self.app_event_tx.send(AppEvent::ExitRequest); } SlashCommand::Undo => { self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); } SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); tokio::spawn(async move { let text = match get_git_diff().await { Ok((is_git_repo, diff_text)) => { if is_git_repo { diff_text } else { "`/diff` — _not inside a git repository_".to_string() } } Err(e) => format!("Failed to compute diff: {e}"), }; tx.send(AppEvent::DiffResult(text)); }); } SlashCommand::Mention => { self.insert_str("@"); } SlashCommand::Status => { self.add_status_output(); } SlashCommand::Mcp => { self.add_mcp_output(); } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( format!("Current rollout path: {}", path.display()), None, ); } else { self.add_info_message("Rollout path is not available yet.".to_string(), None); } } SlashCommand::TestApproval => { use codex_core::protocol::EventMsg; use std::collections::HashMap; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::FileChange; self.app_event_tx.send(AppEvent::CodexEvent(Event { id: "1".to_string(), // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { // call_id: "1".to_string(), // command: vec!["git".into(), "apply".into()], // cwd: self.config.cwd.clone(), // reason: Some("test".to_string()), // }), msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "1".to_string(), changes: HashMap::from([ ( PathBuf::from("/tmp/test.txt"), FileChange::Add { content: "test".to_string(), }, ), ( PathBuf::from("/tmp/test2.txt"), FileChange::Update { unified_diff: "+test\n-test2".to_string(), move_path: None, }, ), ]), reason: None, grant_root: Some(PathBuf::from("/tmp")), }), })); } } } pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } // Returns true if caller should skip rendering this frame (a future frame is scheduled). pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { if self.bottom_pane.flush_paste_burst_if_due() { // A paste just flushed; request an immediate redraw and skip this frame. self.request_redraw(); true } else if self.bottom_pane.is_in_paste_burst() { // While capturing a burst, schedule a follow-up tick and skip this frame // to avoid redundant renders between ticks. frame_requester.schedule_frame_in( crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), ); true } else { false } } fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { self.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } } fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.add_boxed_history(Box::new(cell)); } fn add_boxed_history(&mut self, cell: Box) { if !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); self.needs_final_message_separator = true; } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; if text.is_empty() && image_paths.is_empty() { return; } let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. if let Some(stripped) = text.strip_prefix('!') { let cmd = stripped.trim(); if cmd.is_empty() { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( USER_SHELL_COMMAND_HELP_TITLE.to_string(), Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), ), ))); return; } self.submit_op(Op::RunUserShellCommand { command: cmd.to_string(), }); return; } if !text.is_empty() { items.push(UserInput::Text { text: text.clone() }); } for path in image_paths { items.push(UserInput::LocalImage { path }); } self.codex_op_tx .send(Op::UserInput { items }) .unwrap_or_else(|e| { tracing::error!("failed to send message: {e}"); }); // Persist the text to cross-session message history. if !text.is_empty() { self.codex_op_tx .send(Op::AddToHistory { text: text.clone() }) .unwrap_or_else(|e| { tracing::error!("failed to send AddHistory op: {e}"); }); } // Only show the text portion in conversation history. if !text.is_empty() { self.add_to_history(history_cell::new_user_prompt(text)); } self.needs_final_message_separator = false; } /// Replay a subset of initial events into the UI to seed the transcript when /// resuming an existing session. This approximates the live event flow and /// is intentionally conservative: only safe-to-replay items are rendered to /// avoid triggering side effects. Event ids are passed as `None` to /// distinguish replayed events from live ones. fn replay_initial_messages(&mut self, events: Vec) { for msg in events { if matches!(msg, EventMsg::SessionConfigured(_)) { continue; } // `id: None` indicates a synthetic/fake id coming from replay. self.dispatch_event_msg(None, msg, true); } } pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; self.dispatch_event_msg(Some(id), msg, false); } /// Dispatch a protocol `EventMsg` to the appropriate handler. /// /// `id` is `Some` for live events and `None` for replayed events from /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id /// that must not be used to correlate follow-up actions. fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); } } match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { self.on_agent_message_delta(delta) } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, }) => self.on_agent_reasoning_delta(delta), EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { self.on_agent_reasoning_delta(text); self.on_agent_reasoning_final() } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted(_) => self.on_task_started(), EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { self.on_task_complete(last_agent_message) } EventMsg::TokenCount(ev) => { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { self.on_interrupted_turn(ev.reason); } TurnAbortReason::Replaced => { self.on_error("Turn aborted: replaced by a new task".to_owned()) } TurnAbortReason::ReviewEnded => { self.on_interrupted_turn(ev.reason); } }, EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { // For replayed events, synthesize an empty id (these should not occur). self.on_exec_approval_request(id.unwrap_or_default(), ev) } EventMsg::ApplyPatchApprovalRequest(ev) => { self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { self.on_background_event(message) } EventMsg::UndoStarted(ev) => self.on_undo_started(ev), EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); } } EventMsg::EnteredReviewMode(review_request) => { self.on_entered_review_mode(review_request) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), EventMsg::RawResponseItem(_) | EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) => {} } } fn on_entered_review_mode(&mut self, review: ReviewRequest) { // Enter review mode and emit a concise banner self.is_review_mode = true; let banner = format!(">> Code review started: {} <<", review.user_facing_hint); self.add_to_history(history_cell::new_review_status_line(banner)); self.request_redraw(); } fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { // Leave review mode; if output is present, flush pending stream + show results. if let Some(output) = review.review_output { self.flush_answer_stream_with_separator(); self.flush_interrupt_queue(); self.flush_active_cell(); if output.findings.is_empty() { let explanation = output.overall_explanation.trim().to_string(); if explanation.is_empty() { tracing::error!("Reviewer failed to output a response."); self.add_to_history(history_cell::new_error_event( "Reviewer failed to output a response.".to_owned(), )); } else { // Show explanation when there are no structured findings. let mut rendered: Vec> = vec!["".into()]; append_markdown(&explanation, None, &mut rendered); let body_cell = AgentMessageCell::new(rendered, false); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); } } else { let message_text = codex_core::review_format::format_review_findings_block(&output.findings, None); let mut message_lines: Vec> = Vec::new(); append_markdown(&message_text, None, &mut message_lines); let body_cell = AgentMessageCell::new(message_lines, true); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); } } self.is_review_mode = false; // Append a finishing banner at the end of this turn. self.add_to_history(history_cell::new_review_status_line( "<< Code review finished >>".to_string(), )); self.request_redraw(); } fn on_user_message_event(&mut self, event: UserMessageEvent) { let message = event.message.trim(); if !message.is_empty() { self.add_to_history(history_cell::new_user_prompt(message.to_string())); } } fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } fn notify(&mut self, notification: Notification) { if !notification.allowed_for(&self.config.tui_notifications) { return; } self.pending_notification = Some(notification); self.request_redraw(); } pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { if let Some(notif) = self.pending_notification.take() { tui.notify(notif.display()); } } /// Mark the active cell as failed (✗) and flush it into history. fn finalize_active_cell_as_failed(&mut self) { if let Some(mut cell) = self.active_cell.take() { // Insert finalized cell into history and keep grouping consistent. if let Some(exec) = cell.as_any_mut().downcast_mut::() { exec.mark_failed(); } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { tool.mark_failed(); } self.add_boxed_history(cell); } } // If idle and there are queued inputs, submit exactly one to start the next turn. fn maybe_send_next_queued_input(&mut self) { if self.bottom_pane.is_task_running() { return; } if let Some(user_message) = self.queued_user_messages.pop_front() { self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). self.refresh_queued_user_messages(); } /// Rebuild and update the queued user messages from the current queue. fn refresh_queued_user_messages(&mut self) { let messages: Vec = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect(); self.bottom_pane.set_queued_user_messages(messages); } pub(crate) fn add_diff_in_progress(&mut self) { self.request_redraw(); } pub(crate) fn on_diff_complete(&mut self) { self.request_redraw(); } pub(crate) fn add_status_output(&mut self) { let default_usage = TokenUsage::default(); let (total_usage, context_usage) = if let Some(ti) = &self.token_info { (&ti.total_token_usage, Some(&ti.last_token_usage)) } else { (&default_usage, Some(&default_usage)) }; self.add_to_history(crate::status::new_status_output( &self.config, total_usage, context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), Local::now(), )); } /// Open a popup to choose the model (stage 1). After selecting a model, /// a second popup is shown to choose the reasoning effort. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); let presets: Vec = builtin_model_presets(auth_mode); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { let description = if preset.description.is_empty() { None } else { Some(preset.description.to_string()) }; let is_current = preset.model == current_model; let preset_for_action = preset; let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::OpenReasoningPopup { model: preset_for_action, }); })]; items.push(SelectionItem { name: preset.display_name.to_string(), description, is_current, actions, dismiss_on_select: false, ..Default::default() }); } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Model and Effort".to_string()), subtitle: Some("Switch the model for this and future Codex CLI sessions".to_string()), footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), items, ..Default::default() }); } /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; struct EffortChoice { stored: Option, display: ReasoningEffortConfig, } let mut choices: Vec = Vec::new(); for effort in ReasoningEffortConfig::iter() { if supported.iter().any(|option| option.effort == effort) { choices.push(EffortChoice { stored: Some(effort), display: effort, }); } } if choices.is_empty() { choices.push(EffortChoice { stored: Some(default_effort), display: default_effort, }); } let default_choice: Option = choices .iter() .any(|choice| choice.stored == Some(default_effort)) .then_some(Some(default_effort)) .flatten() .or_else(|| choices.iter().find_map(|choice| choice.stored)) .or(Some(default_effort)); let model_slug = preset.model.to_string(); let is_current_model = self.config.model == preset.model; let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { default_choice }; let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.display; let mut effort_label = effort.to_string(); if let Some(first) = effort_label.get_mut(0..1) { first.make_ascii_uppercase(); } if choice.stored == default_choice { effort_label.push_str(" (default)"); } let description = choice .stored .and_then(|effort| { supported .iter() .find(|option| option.effort == effort) .map(|option| option.description.to_string()) }) .filter(|text| !text.is_empty()); let warning = "⚠ High reasoning effort can quickly consume Plus plan rate limits."; let show_warning = preset.model == "gpt-5-codex" && effort == ReasoningEffortConfig::High; let selected_description = show_warning.then(|| { description .as_ref() .map_or(warning.to_string(), |d| format!("{d}\n{warning}")) }); let model_for_action = model_slug.clone(); let effort_for_action = choice.stored; let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: None, sandbox_policy: None, model: Some(model_for_action.clone()), effort: Some(effort_for_action), summary: None, })); tx.send(AppEvent::UpdateModel(model_for_action.clone())); tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), effort: effort_for_action, }); tracing::info!( "Selected model: {}, Selected effort: {}", model_for_action, effort_for_action .map(|e| e.to_string()) .unwrap_or_else(|| "default".to_string()) ); })]; items.push(SelectionItem { name: effort_label, description, selected_description, is_current: is_current_model && choice.stored == highlight_choice, actions, dismiss_on_select: true, ..Default::default() }); } let mut header = ColumnRenderable::new(); header.push(Line::from( format!("Select Reasoning Level for {model_slug}").bold(), )); self.bottom_pane.show_selection_view(SelectionViewParams { header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). pub(crate) fn open_approvals_popup(&mut self) { let current_approval = self.config.approval_policy; let current_sandbox = self.config.sandbox_policy.clone(); let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); #[cfg(target_os = "windows")] let header_renderable: Box = if self .config .forced_auto_mode_downgraded_on_windows { use ratatui_macros::line; let mut header = ColumnRenderable::new(); header.push(line![ "Codex forced your settings back to Read Only on this Windows machine.".bold() ]); header.push(line![ "To re-enable Auto mode, run Codex inside Windows Subsystem for Linux (WSL) or enable Full Access manually.".dim() ]); Box::new(header) } else { Box::new(()) }; #[cfg(not(target_os = "windows"))] let header_renderable: Box = Box::new(()); for preset in presets.into_iter() { let is_current = current_approval == preset.approval && current_sandbox == preset.sandbox; let name = preset.label.to_string(); let description_text = preset.description; let description = if cfg!(target_os = "windows") && preset.id == "auto" { Some(format!( "{description_text}\nRequires Windows Subsystem for Linux (WSL). Show installation instructions..." )) } else { Some(description_text.to_string()) }; let requires_confirmation = preset.id == "full-access" && !self .config .notices .hide_full_access_warning .unwrap_or(false); let actions: Vec = if requires_confirmation { let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenFullAccessConfirmation { preset: preset_clone.clone(), }); })] } else if cfg!(target_os = "windows") && preset.id == "auto" { vec![Box::new(|tx| { tx.send(AppEvent::ShowWindowsAutoModeInstructions); })] } else { Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) }; items.push(SelectionItem { name, description, is_current, actions, dismiss_on_select: true, ..Default::default() }); } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Approval Mode".to_string()), footer_hint: Some(standard_popup_hint_line()), items, header: header_renderable, ..Default::default() }); } fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, ) -> Vec { vec![Box::new(move |tx| { let sandbox_clone = sandbox.clone(); tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: Some(approval), sandbox_policy: Some(sandbox_clone.clone()), model: None, effort: None, summary: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); })] } pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { let approval = preset.approval; let sandbox = preset.sandbox; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); let info_line = Line::from(vec![ "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " .into(), "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." .fg(Color::Red), ]); header_children.push(Box::new(title_line)); header_children.push(Box::new( Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), )); let header = ColumnRenderable::with(header_children); let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); })); let deny_actions: Vec = vec![Box::new(|tx| { tx.send(AppEvent::OpenApprovalsPopup); })]; let items = vec![ SelectionItem { name: "Yes, continue anyway".to_string(), description: Some("Apply full access for this session".to_string()), actions: accept_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Yes, and don't ask again".to_string(), description: Some("Enable full access and remember this choice".to_string()), actions: accept_and_remember_actions, dismiss_on_select: true, ..Default::default() }, SelectionItem { name: "Cancel".to_string(), description: Some("Go back without enabling full access".to_string()), actions: deny_actions, dismiss_on_select: true, ..Default::default() }, ]; self.bottom_pane.show_selection_view(SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); } #[cfg(target_os = "windows")] pub(crate) fn open_windows_auto_mode_instructions(&mut self) { use ratatui_macros::line; let mut header = ColumnRenderable::new(); header.push(line![ "Auto mode requires Windows Subsystem for Linux (WSL2).".bold() ]); header.push(line!["Run Codex inside WSL to enable sandboxed commands."]); header.push(line![""]); header.push(Paragraph::new(WSL_INSTRUCTIONS).wrap(Wrap { trim: false })); let items = vec![SelectionItem { name: "Back".to_string(), description: Some( "Return to the approval mode list. Auto mode stays disabled outside WSL." .to_string(), ), actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenApprovalsPopup); })], dismiss_on_select: true, ..Default::default() }]; self.bottom_pane.show_selection_view(SelectionViewParams { title: None, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(header), ..Default::default() }); } #[cfg(not(target_os = "windows"))] pub(crate) fn open_windows_auto_mode_instructions(&mut self) {} /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { self.config.approval_policy = policy; } /// Set the sandbox policy in the widget's config copy. pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { self.config.sandbox_policy = policy; } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_full_access_warning = Some(acknowledged); } /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; } /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); self.config.model = model.to_string(); } 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(); } pub(crate) fn add_error_message(&mut self, message: String) { self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); } pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); } else { self.submit_op(Op::ListMcpTools); } } /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); } /// Handle Ctrl-C key press. fn on_ctrl_c(&mut self) { if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { return; } if self.bottom_pane.is_task_running() { self.bottom_pane.show_ctrl_c_quit_hint(); self.submit_op(Op::Interrupt); return; } self.submit_op(Op::Shutdown); } pub(crate) fn composer_is_empty(&self) -> bool { self.bottom_pane.composer_is_empty() } /// True when the UI is in the regular composer state with no running task, /// no modal overlay (e.g. approvals or status indicator), and no composer popups. /// In this state Esc-Esc backtracking is enabled. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { self.bottom_pane.is_normal_backtrack_mode() } pub(crate) fn insert_str(&mut self, text: &str) { self.bottom_pane.insert_str(text); } /// Replace the composer content with the provided text and reset cursor. pub(crate) fn set_composer_text(&mut self, text: String) { self.bottom_pane.set_composer_text(text); } pub(crate) fn show_esc_backtrack_hint(&mut self) { self.bottom_pane.show_esc_backtrack_hint(); } pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { // Record outbound operation for session replay fidelity. crate::session_log::log_outbound_op(&op); if let Err(e) = self.codex_op_tx.send(op) { tracing::error!("failed to submit op: {e}"); } } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, ev.tools, ev.resources, ev.resource_templates, &ev.auth_statuses, )); } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { let len = ev.custom_prompts.len(); debug!("received {len} custom prompts"); // Forward to bottom pane so the slash popup can show them now. self.bottom_pane.set_custom_prompts(ev.custom_prompts); } pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); items.push(SelectionItem { name: "Review against a base branch".to_string(), description: Some("(PR Style)".into()), actions: vec![Box::new({ let cwd = self.config.cwd.clone(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } })], dismiss_on_select: false, ..Default::default() }); items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new( move |tx: &AppEventSender| { tx.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(), user_facing_hint: "current changes".to_string(), }, })); }, )], dismiss_on_select: true, ..Default::default() }); // New: Review a specific commit (opens commit picker) items.push(SelectionItem { name: "Review a commit".to_string(), actions: vec![Box::new({ let cwd = self.config.cwd.clone(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } })], dismiss_on_select: false, ..Default::default() }); items.push(SelectionItem { name: "Custom review instructions".to_string(), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenReviewCustomPrompt); })], dismiss_on_select: false, ..Default::default() }); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a review preset".into()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() }); } pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { let branches = local_git_branches(cwd).await; let current_branch = current_branch_name(cwd) .await .unwrap_or_else(|| "(detached HEAD)".to_string()); let mut items: Vec = Vec::with_capacity(branches.len()); for option in branches { let branch = option.clone(); items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { prompt: format!( "Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{{upstream}}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings." ), user_facing_hint: format!("changes against '{branch}'"), }, })); })], dismiss_on_select: true, search_value: Some(option), ..Default::default() }); } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a base branch".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search branches".to_string()), ..Default::default() }); } pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { let commits = codex_core::git_info::recent_commits(cwd, 100).await; let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { let subject = entry.subject.clone(); let sha = entry.sha.clone(); let short = sha.chars().take(7).collect::(); let search_val = format!("{subject} {sha}"); items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { let hint = format!("commit {short}"); let prompt = format!( "Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings." ); tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { prompt, user_facing_hint: hint, }, })); })], dismiss_on_select: true, search_value: Some(search_val), ..Default::default() }); } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a commit to review".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), ..Default::default() }); } pub(crate) fn show_review_custom_prompt(&mut self) { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( "Custom review instructions".to_string(), "Type instructions and press Enter".to_string(), None, Box::new(move |prompt: String| { let trimmed = prompt.trim().to_string(); if trimmed.is_empty() { return; } tx.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { prompt: trimmed.clone(), user_facing_hint: trimmed, }, })); }), ); self.bottom_pane.show_view(Box::new(view)); } /// Programmatically submit a user text message as if typed in the /// composer. The text will be added to conversation history and sent to /// the agent. pub(crate) fn submit_text_message(&mut self, text: String) { if text.is_empty() { return; } self.submit_user_message(text.into()); } pub(crate) fn token_usage(&self) -> TokenUsage { self.token_info .as_ref() .map(|ti| ti.total_token_usage.clone()) .unwrap_or_default() } pub(crate) fn conversation_id(&self) -> Option { self.conversation_id } pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { &self.config } pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let [_, _, bottom_pane_area] = self.layout_areas(area); self.bottom_pane.cursor_pos(bottom_pane_area) } } impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); if !active_cell_area.is_empty() && let Some(cell) = &self.active_cell { let mut area = active_cell_area; area.y = area.y.saturating_add(1); area.height = area.height.saturating_sub(1); if let Some(exec) = cell.as_any().downcast_ref::() { exec.render_ref(area, buf); } else if let Some(tool) = cell.as_any().downcast_ref::() { tool.render_ref(area, buf); } } self.last_rendered_width.set(Some(area.width as usize)); } } enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, EditApprovalRequested { cwd: PathBuf, changes: Vec }, } impl Notification { fn display(&self) -> String { match self { Notification::AgentTurnComplete { response } => { Notification::agent_turn_preview(response) .unwrap_or_else(|| "Agent turn complete".to_string()) } Notification::ExecApprovalRequested { command } => { format!("Approval requested: {}", truncate_text(command, 30)) } Notification::EditApprovalRequested { cwd, changes } => { format!( "Codex wants to edit {}", if changes.len() == 1 { #[allow(clippy::unwrap_used)] display_path_for(changes.first().unwrap(), cwd) } else { format!("{} files", changes.len()) } ) } } } fn type_name(&self) -> &str { match self { Notification::AgentTurnComplete { .. } => "agent-turn-complete", Notification::ExecApprovalRequested { .. } | Notification::EditApprovalRequested { .. } => "approval-requested", } } fn allowed_for(&self, settings: &Notifications) -> bool { match settings { Notifications::Enabled(enabled) => *enabled, Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), } } fn agent_turn_preview(response: &str) -> Option { let mut normalized = String::new(); for part in response.split_whitespace() { if !normalized.is_empty() { normalized.push(' '); } normalized.push_str(part); } let trimmed = normalized.trim(); if trimmed.is_empty() { None } else { Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) } } } const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; const EXAMPLE_PROMPTS: [&str; 6] = [ "Explain this codebase", "Summarize recent commits", "Implement {feature}", "Find and fix a bug in @filename", "Write tests for @filename", "Improve documentation in @filename", ]; // Extract the first bold (Markdown) element in the form **...** from `s`. // Returns the inner text if found; otherwise `None`. fn extract_first_bold(s: &str) -> Option { let bytes = s.as_bytes(); let mut i = 0usize; while i + 1 < bytes.len() { if bytes[i] == b'*' && bytes[i + 1] == b'*' { let start = i + 2; let mut j = start; while j + 1 < bytes.len() { if bytes[j] == b'*' && bytes[j + 1] == b'*' { // Found closing ** let inner = &s[start..j]; let trimmed = inner.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } else { return None; } } j += 1; } // No closing; stop searching (wait for more deltas) return None; } i += 1; } None } #[cfg(test)] pub(crate) fn show_review_commit_picker_with_entries( chat: &mut ChatWidget, entries: Vec, ) { let mut items: Vec = Vec::with_capacity(entries.len()); for entry in entries { let subject = entry.subject.clone(); let sha = entry.sha.clone(); let short = sha.chars().take(7).collect::(); let search_val = format!("{subject} {sha}"); items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { let hint = format!("commit {short}"); let prompt = format!( "Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings." ); tx3.send(AppEvent::CodexOp(Op::Review { review_request: ReviewRequest { prompt, user_facing_hint: hint, }, })); })], dismiss_on_select: true, search_value: Some(search_val), ..Default::default() }); } chat.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select a commit to review".to_string()), footer_hint: Some(standard_popup_hint_line()), items, is_searchable: true, search_placeholder: Some("Type to search commits".to_string()), ..Default::default() }); } #[cfg(test)] pub(crate) mod tests;