//! The bottom pane is the interactive footer of the chat UI.//!//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused//! interactions like selection lists.//!//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active//! view the first chance to consume Ctrl+C (typically to dismiss itself), then lets an active//! composer history search consume Ctrl+C as cancellation, and `ChatWidget` may treat an unhandled//! Ctrl+C as an interrupt or as the first press of a double-press quit shortcut.//!//! Some UI is time-based rather than input-based, such as the transient "press again to quit"//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.use std::collections::VecDeque;use std::path::PathBuf;use crate::app::app_server_requests::ResolvedAppServerRequest;use crate::app_event::AppEvent;use crate::app_event::ConnectorsSnapshot;use crate::app_event_sender::AppEventSender;use crate::bottom_pane::pending_input_preview::PendingInputPreview;use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;use crate::key_hint;use crate::key_hint::KeyBinding;use crate::key_hint::KeyBindingListExt;use crate::keymap::RuntimeKeymap;use crate::keymap::primary_binding;use crate::render::renderable::FlexRenderable;use crate::render::renderable::Renderable;use crate::render::renderable::RenderableItem;use crate::tui::FrameRequester;pub(crate) use bottom_pane_view::BottomPaneView;pub(crate) use bottom_pane_view::ViewCompletion;use codex_app_server_protocol::ToolRequestUserInputParams;use codex_core_skills::model::SkillMetadata;use codex_features::Features;use codex_file_search::FileMatch;use codex_plugin::PluginCapabilitySummary;use codex_protocol::ThreadId;use codex_protocol::user_input::TextElement;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::text::Line;use std::time::Duration;use std::time::Instant;mod action_required_title;mod app_link_view;mod approval_overlay;mod mcp_server_elicitation;mod multi_select_picker;mod request_user_input;mod status_line_setup;mod status_line_style;mod status_surface_preview;mod title_setup;pub(crate) use action_required_title::ACTION_REQUIRED_PREVIEW_PREFIX;pub(crate) use action_required_title::build_action_required_title_text;pub(crate) use app_link_view::AppLinkElicitationTarget;pub(crate) use app_link_view::AppLinkSuggestionType;pub(crate) use app_link_view::AppLinkView;pub(crate) use app_link_view::AppLinkViewParams;pub(crate) use approval_overlay::ApprovalOverlay;pub(crate) use approval_overlay::ApprovalRequest;pub(crate) use approval_overlay::format_requested_permissions_rule;pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;pub(crate) use request_user_input::RequestUserInputOverlay;pub(crate) use status_line_style::status_line_from_segments;mod bottom_pane_view;#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct LocalImageAttachment { pub(crate) placeholder: String, pub(crate) path: PathBuf,}#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct MentionBinding { /// Visible mention sigil (`$` or `@`). pub(crate) sigil: char, /// Mention token text without the leading sigil (`$` or `@`). pub(crate) mention: String, /// Canonical mention target (for example `app://...` or absolute SKILL.md path). pub(crate) path: String,}mod chat_composer;mod chat_composer_history;mod command_popup;pub(crate) mod custom_prompt_view;mod experimental_features_view;mod file_search_popup;mod footer;mod list_selection_view;mod memories_settings_view;mod mentions_v2;pub(crate) mod prompt_args;mod skill_popup;mod skills_toggle_view;pub(crate) mod slash_commands;pub(crate) use footer::CollaborationModeIndicator;pub(crate) use footer::GoalStatusIndicator;#[cfg(test)]pub(crate) use footer::goal_status_indicator_line;pub(crate) use list_selection_view::ColumnWidthMode;pub(crate) use list_selection_view::ListSelectionView;pub(crate) use list_selection_view::OnSelectionChangedCallback;pub(crate) use list_selection_view::SelectionRowDisplay;pub(crate) use list_selection_view::SelectionToggle;pub(crate) use list_selection_view::SelectionViewParams;pub(crate) use list_selection_view::SideContentWidth;pub(crate) use list_selection_view::popup_content_width;pub(crate) use list_selection_view::side_by_side_layout_widths;pub(crate) use memories_settings_view::MemoriesSettingsView;use slash_commands::ServiceTierCommand;mod feedback_view;mod hooks_browser_view;pub(crate) use feedback_view::FeedbackAudience;pub(crate) use feedback_view::feedback_classification;pub(crate) use feedback_view::feedback_disabled_params;pub(crate) use feedback_view::feedback_selection_params;pub(crate) use feedback_view::feedback_success_cell;pub(crate) use feedback_view::feedback_upload_consent_params;pub(crate) use skills_toggle_view::SkillsToggleItem;pub(crate) use skills_toggle_view::SkillsToggleView;pub(crate) use status_line_setup::StatusLineItem;pub(crate) use status_line_setup::StatusLineSetupView;pub(crate) use status_surface_preview::StatusSurfacePreviewData;pub(crate) use status_surface_preview::StatusSurfacePreviewItem;pub(crate) use title_setup::TerminalTitleItem;pub(crate) use title_setup::TerminalTitleSetupView;#[cfg(test)]pub(crate) use title_setup::preview_line_for_title_items;mod paste_burst;mod pending_input_preview;mod pending_thread_approvals;pub(crate) mod popup_consts;mod scroll_state;mod selection_popup_common;mod selection_tabs;mod textarea;mod unified_exec_footer;pub(crate) use feedback_view::FeedbackNoteView;pub(crate) use hooks_browser_view::HooksBrowserView;pub(crate) use selection_tabs::SelectionTab;/// How long the "press again to quit" hint stays visible.////// This is shared between:/// - `ChatWidget`: arming the double-press quit shortcut./// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint.////// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);const APPROVAL_PROMPT_TYPING_IDLE_DELAY: Duration = Duration::from_secs(1);/// Whether Ctrl+C/Ctrl+D require a second press to quit.////// This UX experiment was enabled by default, but requiring a double press to quit feels janky in/// practice (especially for users accustomed to shells and other TUIs). Disable it for now while we/// rethink a better quit/interrupt design.pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false;/// The result of offering a cancellation key to a bottom-pane surface.////// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss/// themselves, and the caller can decide what higher-level action (if any) to take when the key is/// not handled locally.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub(crate) enum CancellationEvent { Handled, NotHandled,}use crate::bottom_pane::prompt_args::parse_slash_name;pub(crate) use chat_composer::ChatComposer;pub(crate) use chat_composer::ChatComposerConfig;pub(crate) use chat_composer::InputResult;pub(crate) use chat_composer::QueuedInputAction;pub(crate) use chat_composer_history::HistoryEntry;use crate::status_indicator_widget::StatusDetailsCapitalization;use crate::status_indicator_widget::StatusIndicatorWidget;pub(crate) use experimental_features_view::ExperimentalFeatureItem;pub(crate) use experimental_features_view::ExperimentalFeaturesView;pub(crate) use list_selection_view::SelectionAction;pub(crate) use list_selection_view::SelectionItem;struct DelayedApprovalRequest { request: ApprovalRequest, features: Features,}/// Pane displayed in the lower half of the chat UI.////// This is the owning container for the prompt input (`ChatComposer`) and the view stack/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`.pub(crate) struct BottomPane { /// Composer is retained even when a BottomPaneView is displayed so the /// input state is retained when the view is closed. composer: ChatComposer, /// Stack of views displayed instead of the composer (e.g. popups/modals). view_stack: Vec<Box<dyn BottomPaneView>>, delayed_approval_requests: VecDeque<DelayedApprovalRequest>, last_composer_activity_at: Option<Instant>, app_event_tx: AppEventSender, frame_requester: FrameRequester, thread_id: Option<ThreadId>, has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, is_task_running: bool, esc_backtrack_hint: bool, animations_enabled: bool, /// Inline status indicator shown above the composer while a task is running. status: Option<StatusIndicatorWidget>, /// Unified exec session summary source. /// /// When a status row exists, this summary is mirrored inline in that row; /// when no status row exists, it renders as its own footer row. unified_exec_footer: UnifiedExecFooter, /// Preview of pending steers and queued drafts shown above the composer. pending_input_preview: PendingInputPreview, /// Inactive threads with pending approval requests. pending_thread_approvals: PendingThreadApprovals, context_window_percent: Option<i64>, context_window_used_tokens: Option<i64>, keymap: RuntimeKeymap,}pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, pub(crate) frame_requester: FrameRequester, pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, pub(crate) skills: Option<Vec<SkillMetadata>>,}impl BottomPane { pub fn new(params: BottomPaneParams) -> Self { let BottomPaneParams { app_event_tx, frame_requester, has_input_focus, enhanced_keys_supported, placeholder_text, disable_paste_burst, animations_enabled, skills, } = params; let mut composer = ChatComposer::new( has_input_focus, app_event_tx.clone(), enhanced_keys_supported, placeholder_text, disable_paste_burst, ); composer.set_frame_requester(frame_requester.clone()); let keymap = RuntimeKeymap::defaults(); composer.set_keymap_bindings(&keymap); composer.set_skill_mentions(skills); Self { composer, view_stack: Vec::new(), delayed_approval_requests: VecDeque::new(), last_composer_activity_at: None, app_event_tx, frame_requester, thread_id: None, has_input_focus, enhanced_keys_supported, disable_paste_burst, is_task_running: false, status: None, unified_exec_footer: UnifiedExecFooter::new(), pending_input_preview: PendingInputPreview::new(), pending_thread_approvals: PendingThreadApprovals::new(), esc_backtrack_hint: false, animations_enabled, context_window_percent: None, context_window_used_tokens: None, keymap, } } pub fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) { self.composer.set_skill_mentions(skills); self.request_redraw(); } /// Update image-paste behavior for the active composer and repaint immediately. /// /// Callers use this to keep composer affordances aligned with model capabilities. pub fn set_image_paste_enabled(&mut self, enabled: bool) { self.composer.set_image_paste_enabled(enabled); self.request_redraw(); } pub fn set_connectors_snapshot(&mut self, snapshot: Option<ConnectorsSnapshot>) { self.composer.set_connector_mentions(snapshot); self.request_redraw(); } pub fn set_plugin_mentions(&mut self, plugins: Option<Vec<PluginCapabilitySummary>>) { self.composer.set_plugin_mentions(plugins); self.request_redraw(); } pub fn set_plugins_command_enabled(&mut self, enabled: bool) { self.composer.set_plugins_command_enabled(enabled); self.request_redraw(); } pub fn set_token_activity_command_enabled(&mut self, enabled: bool) { self.composer.set_token_activity_command_enabled(enabled); self.request_redraw(); } pub fn set_mentions_v2_enabled(&mut self, enabled: bool) { self.composer.set_mentions_v2_enabled(enabled); self.request_redraw(); } pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> { self.composer.take_mention_bindings() } pub fn take_recent_submission_mention_bindings(&mut self) -> Vec<MentionBinding> { self.composer.take_recent_submission_mention_bindings() } /// Add a staged slash-command draft to the composer's local recall list. /// /// This should be called exactly once after `ChatWidget` dispatches a recognized command. /// Slash recall records the submitted command text regardless of whether the command succeeds. pub(crate) fn record_pending_slash_command_history(&mut self) { self.composer.record_pending_slash_command_history(); } /// Replace all bottom-pane keymap caches from one resolved runtime keymap. /// /// The bottom pane owns several input surfaces: composer, overlays, and /// selection views. Applying one snapshot through this method keeps those /// surfaces synchronized after config reloads or interactive remaps. Callers /// should not update the composer directly unless they deliberately want /// overlays and selection views to continue using the previous bindings. pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { self.keymap = keymap.clone(); self.composer.set_keymap_bindings(keymap); let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn); self.pending_input_preview .set_interrupt_binding(interrupt_binding); if let Some(status) = self.status.as_mut() { status.set_interrupt_binding(interrupt_binding); } self.request_redraw(); } /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. pub(crate) fn drain_pending_submission_state(&mut self) { let _ = self.take_recent_submission_images_with_placeholders(); let _ = self.take_remote_image_urls(); let _ = self.take_recent_submission_mention_bindings(); let _ = self.take_mention_bindings(); } pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { self.composer.set_collaboration_modes_enabled(enabled); self.request_redraw(); } pub fn set_connectors_enabled(&mut self, enabled: bool) { self.composer.set_connectors_enabled(enabled); } #[cfg(target_os = "windows")] pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { self.composer.set_windows_degraded_sandbox_active(enabled); self.request_redraw(); } pub fn set_collaboration_mode_indicator( &mut self, indicator: Option<CollaborationModeIndicator>, ) { self.composer.set_collaboration_mode_indicator(indicator); self.request_redraw(); } pub fn set_goal_status_indicator(&mut self, indicator: Option<GoalStatusIndicator>) { self.composer.set_goal_status_indicator(indicator); self.request_redraw(); } pub fn set_ide_context_active(&mut self, active: bool) { self.composer.set_ide_context_active(active); self.request_redraw(); } pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.composer.set_personality_command_enabled(enabled); self.request_redraw(); } pub fn set_service_tier_commands_enabled(&mut self, enabled: bool) { self.composer.set_service_tier_commands_enabled(enabled); self.request_redraw(); } pub fn set_service_tier_commands(&mut self, commands: Vec<ServiceTierCommand>) { self.composer.set_service_tier_commands(commands); self.request_redraw(); } pub fn set_goal_command_enabled(&mut self, enabled: bool) { self.composer.set_goal_command_enabled(enabled); self.request_redraw(); } pub(crate) fn set_side_conversation_active(&mut self, active: bool) { self.composer.set_side_conversation_active(active); self.request_redraw(); } pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { self.composer.set_placeholder_text(placeholder); self.request_redraw(); } /// Update the key hint shown next to queued messages so it matches the /// binding that `ChatWidget` actually listens for. pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) { self.pending_input_preview.set_edit_binding(binding); self.request_redraw(); } pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.composer.set_vim_enabled(enabled); self.request_redraw(); } pub(crate) fn toggle_vim_enabled(&mut self) -> bool { let enabled = self.composer.toggle_vim_enabled(); self.request_redraw(); enabled } pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } pub fn skills(&self) -> Option<&Vec<SkillMetadata>> { self.composer.skills() } pub fn plugins(&self) -> Option<&Vec<PluginCapabilitySummary>> { self.composer.plugins() } #[cfg(test)] pub(crate) fn context_window_percent(&self) -> Option<i64> { self.context_window_percent } #[cfg(test)] pub(crate) fn context_window_used_tokens(&self) -> Option<i64> { self.context_window_used_tokens } fn active_view(&self) -> Option<&dyn BottomPaneView> { self.view_stack.last().map(std::convert::AsRef::as_ref) } fn push_view(&mut self, view: Box<dyn BottomPaneView>) { self.view_stack.push(view); self.schedule_active_view_frame(); self.request_redraw(); } fn pop_active_view_with_completion(&mut self, completion: Option<ViewCompletion>) { if self.view_stack.pop().is_some() { match completion { Some(ViewCompletion::Accepted) => { while self .view_stack .last() .is_some_and(|view| view.dismiss_after_child_accept()) { self.view_stack.pop(); } } Some(ViewCompletion::Cancelled) => { if let Some(view) = self.view_stack.last_mut() { view.clear_dismiss_after_child_accept(); } } None => {} } self.on_view_stack_depth_decreased(); } } fn on_view_stack_depth_decreased(&mut self) { if self.view_stack.is_empty() { self.on_active_view_complete(); } } fn approval_prompt_delay_remaining(&self, now: Instant) -> Option<Duration> { self.last_composer_activity_at.and_then(|last_activity_at| { last_activity_at .checked_add(APPROVAL_PROMPT_TYPING_IDLE_DELAY) .and_then(|show_at| show_at.checked_duration_since(now)) .filter(|delay| !delay.is_zero()) }) } fn record_composer_activity_at(&mut self, now: Instant) { self.last_composer_activity_at = Some(now); if !self.delayed_approval_requests.is_empty() && let Some(delay) = self.approval_prompt_delay_remaining(now) { self.request_redraw_in(delay); } } fn maybe_show_delayed_approval_requests_at(&mut self, now: Instant) { if self.delayed_approval_requests.is_empty() || !self.view_stack.is_empty() { return; } if let Some(delay) = self.approval_prompt_delay_remaining(now) { self.request_redraw_in(delay); return; } // Promote the oldest delayed approval once typing has been idle long enough. // `ApprovalOverlay` advances its internal queue with `pop()`, so drain the // remaining delayed approvals from the back to preserve FIFO display order. let Some(first) = self.delayed_approval_requests.pop_front() else { return; }; let mut modal = ApprovalOverlay::new( first.request, self.app_event_tx.clone(), first.features, self.keymap.approval.clone(), self.keymap.list.clone(), ); while let Some(delayed) = self.delayed_approval_requests.pop_back() { modal.enqueue_request(delayed.request); } self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { // If a modal/view is active, handle it here; otherwise forward to composer. if !self.view_stack.is_empty() { if key_event.kind == KeyEventKind::Release { return InputResult::None; } // We need three pieces of information after routing the key: // whether Esc completed the view, whether the view finished for any // reason, and whether a paste-burst timer should be scheduled. let (ctrl_c_completed, view_complete, completion, view_in_paste_burst) = { let last_index = self.view_stack.len() - 1; let view = &mut self.view_stack[last_index]; let prefer_esc = key_event.code == KeyCode::Esc && view.prefer_esc_to_handle_key_event(); let ctrl_c_completed = key_event.code == KeyCode::Esc && !prefer_esc && matches!(view.on_ctrl_c(), CancellationEvent::Handled) && view.is_complete(); if ctrl_c_completed { (true, true, view.completion(), false) } else { view.handle_key_event(key_event); ( false, view.is_complete(), view.completion(), view.is_in_paste_burst(), ) } }; if ctrl_c_completed { self.pop_active_view_with_completion(completion); if let Some(next_view) = self.view_stack.last() && next_view.is_in_paste_burst() { self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } } else if view_complete { self.pop_active_view_with_completion(completion); } else if view_in_paste_burst { self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } self.request_redraw(); InputResult::None } else { let is_agent_command = self .composer_text() .lines() .next() .and_then(parse_slash_name) .is_some_and(|(name, _, _)| name == "agent"); // If a task is running and a status line is visible, allow the // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. if self.keymap.chat.interrupt_turn.is_pressed(key_event) && self.is_task_running && !(is_agent_command && key_event.code == KeyCode::Esc) && !self.composer.popup_active() && !self.composer_should_handle_vim_insert_escape(key_event) && let Some(status) = &self.status { // Send Op::Interrupt status.interrupt(); self.request_redraw(); return InputResult::None; } let records_composer_activity = matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && !key_hint::has_ctrl_or_alt(key_event.modifiers) && matches!( key_event.code, KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete | KeyCode::Enter | KeyCode::Tab ); let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); if records_composer_activity { self.record_composer_activity_at(Instant::now()); } if needs_redraw { self.request_redraw(); } if self.composer.is_in_paste_burst() { self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } input_result } } /// Handles a Ctrl+C press within the bottom pane. /// /// An active modal view is given the first chance to consume the key (typically to dismiss /// itself). If no view is active, Ctrl+C cancels active history search before falling back to /// clearing draft composer input. /// /// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C /// was received, but it does not decide whether the process should exit; `ChatWidget` owns the /// quit/interrupt state machine and uses the result to decide what happens next. pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { if let Some(view) = self.view_stack.last_mut() { let event = view.on_ctrl_c(); let view_complete = view.is_complete(); let completion = view.completion(); if matches!(event, CancellationEvent::Handled) { if view_complete { self.pop_active_view_with_completion(completion); } self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); self.request_redraw(); } event } else if self.composer.cancel_history_search() { self.request_redraw(); CancellationEvent::Handled } else if self.composer_is_empty() { CancellationEvent::NotHandled } else { self.view_stack.pop(); self.clear_composer_for_ctrl_c(); self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); self.request_redraw(); CancellationEvent::Handled } } pub fn handle_paste(&mut self, pasted: String) { let has_pasted_text = !pasted.is_empty(); if let Some(view) = self.view_stack.last_mut() { let needs_redraw = view.handle_paste(pasted); let view_complete = view.is_complete(); if view_complete { self.view_stack.clear(); self.on_active_view_complete(); } if needs_redraw || view_complete { self.request_redraw(); } } else { let needs_redraw = self.composer.handle_paste(pasted); if has_pasted_text { self.record_composer_activity_at(Instant::now()); } if needs_redraw { self.request_redraw(); } } } pub(crate) fn insert_str(&mut self, text: &str) { self.composer.insert_str(text); self.request_redraw(); } pub(crate) fn pre_draw_tick(&mut self) { self.pre_draw_tick_at(Instant::now()); } fn pre_draw_tick_at(&mut self, now: Instant) { self.composer.sync_popups(); self.maybe_show_delayed_approval_requests_at(now); self.tick_active_view(now); self.schedule_active_view_frame(); } fn tick_active_view(&mut self, now: Instant) { let Some(view) = self.view_stack.last_mut() else { return; }; let needs_redraw = view.pre_draw_tick(now); let view_complete = view.is_complete(); if view_complete { self.view_stack.clear(); self.on_active_view_complete(); } if needs_redraw || view_complete { self.request_redraw(); } } fn schedule_active_view_frame(&self) { if let Some(delay) = self .active_view() .and_then(BottomPaneView::next_frame_delay) { self.request_redraw_in(delay); } } /// Replace the composer text with `text`. /// /// This is intended for fresh input where mention linkage does not need to /// survive; it routes to `ChatComposer::set_text_content`, which resets /// mention bindings. pub(crate) fn set_composer_text( &mut self, text: String, text_elements: Vec<TextElement>, local_image_paths: Vec<PathBuf>, ) { self.composer .set_text_content(text, text_elements, local_image_paths); self.composer.move_cursor_to_end(); self.request_redraw(); } /// Replace the composer text while preserving mention link targets. /// /// Use this when rehydrating a draft after a local validation/gating /// failure (for example unsupported image submit) so previously selected /// mention targets remain stable across retry. pub(crate) fn set_composer_text_with_mention_bindings( &mut self, text: String, text_elements: Vec<TextElement>, local_image_paths: Vec<PathBuf>, mention_bindings: Vec<MentionBinding>, ) { self.composer.set_text_content_with_mention_bindings( text, text_elements, local_image_paths, mention_bindings, ); self.composer.move_cursor_to_end(); self.request_redraw(); } #[allow(dead_code)] pub(crate) fn set_composer_input_enabled( &mut self, enabled: bool, placeholder: Option<String>, ) { self.composer.set_input_enabled(enabled, placeholder); self.request_redraw(); } pub(crate) fn show_shutdown_in_progress(&mut self) { self.view_stack.clear(); self.composer.show_shutdown_in_progress(); self.request_redraw(); } pub(crate) fn clear_composer_for_ctrl_c(&mut self) { if let Some(text) = self.composer.clear_for_ctrl_c() { if let Some(thread_id) = self.thread_id { self.app_event_tx .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); } else { tracing::warn!( "failed to append Ctrl+C-cleared draft to history: no active thread id" ); } } self.request_redraw(); } /// Get the current composer text (for tests and programmatic checks). pub(crate) fn composer_text(&self) -> String { self.composer.current_text() } #[cfg(test)] pub(crate) fn composer_cursor(&self) -> usize { self.composer.cursor() } pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { self.composer.draft_snapshot() } #[cfg(test)] pub(crate) fn composer_text_elements(&self) -> Vec<TextElement> { self.composer.text_elements() } pub(crate) fn composer_local_images(&self) -> Vec<LocalImageAttachment> { self.composer.local_images() } #[cfg(test)] pub(crate) fn composer_local_image_paths(&self) -> Vec<PathBuf> { self.composer.local_image_paths() } pub(crate) fn composer_text_with_pending(&self) -> String { self.composer.current_text_with_pending() } /// Returns whether the composer currently accepts interactive draft edits. pub(crate) fn composer_input_enabled(&self) -> bool { self.composer.input_enabled() } pub(crate) fn composer_pending_pastes(&self) -> Vec<(String, String)> { self.composer.pending_pastes() } pub(crate) fn apply_external_edit(&mut self, text: String) { self.composer.apply_external_edit(text); self.request_redraw(); } pub(crate) fn set_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) { self.composer.set_footer_hint_override(items); self.request_redraw(); } /// Applies the externally decided Plan-mode nudge visibility to the footer presentation. pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) { if self.composer.set_plan_mode_nudge_visible(visible) { self.request_redraw(); } } #[cfg(test)] pub(crate) fn plan_mode_nudge_visible(&self) -> bool { self.composer.plan_mode_nudge_visible() } pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) { self.composer.set_remote_image_urls(urls); self.request_redraw(); } pub(crate) fn remote_image_urls(&self) -> Vec<String> { self.composer.remote_image_urls() } pub(crate) fn take_remote_image_urls(&mut self) -> Vec<String> { let urls = self.composer.take_remote_image_urls(); self.request_redraw(); urls } pub(crate) fn set_composer_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { self.composer.set_pending_pastes(pending_pastes); self.request_redraw(); } /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. pub(crate) fn update_status( &mut self, header: String, details: Option<String>, details_capitalization: StatusDetailsCapitalization, details_max_lines: usize, ) { if let Some(status) = self.status.as_mut() { status.update_header(header); status.update_details(details, details_capitalization, details_max_lines.max(1)); self.request_redraw(); } } /// Show the transient "press again to quit" hint for `key`. /// /// `ChatWidget` owns the quit shortcut state machine (it decides when quit is /// allowed), while the bottom pane owns rendering. We also schedule a redraw /// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user /// stops typing and no other events trigger a draw. pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) { if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { return; } self.composer .show_quit_shortcut_hint(key, self.has_input_focus); let frame_requester = self.frame_requester.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await; frame_requester.schedule_frame(); }); } else { // In tests (and other non-Tokio contexts), fall back to a thread so // the hint can still expire without requiring an explicit draw. std::thread::spawn(move || { std::thread::sleep(QUIT_SHORTCUT_TIMEOUT); frame_requester.schedule_frame(); }); } self.request_redraw(); } /// Clear the "press again to quit" hint immediately. pub(crate) fn clear_quit_shortcut_hint(&mut self) { self.composer.clear_quit_shortcut_hint(self.has_input_focus); self.request_redraw(); } #[cfg(test)] pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { self.composer.quit_shortcut_hint_visible() } #[cfg(test)] pub(crate) fn status_indicator_visible(&self) -> bool { self.status.is_some() } #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option<String> { self.composer.status_line_text() } pub(crate) fn show_esc_backtrack_hint(&mut self) { self.esc_backtrack_hint = true; self.composer.set_esc_backtrack_hint(/*show*/ true); self.request_redraw(); } pub(crate) fn clear_esc_backtrack_hint(&mut self) { if self.esc_backtrack_hint { self.esc_backtrack_hint = false; self.composer.set_esc_backtrack_hint(/*show*/ false); self.request_redraw(); } } // esc_backtrack_hint_visible removed; hints are controlled internally. pub fn set_task_running(&mut self, running: bool) { let was_running = self.is_task_running; self.is_task_running = running; self.composer.set_task_running(running); if running { if !was_running { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, )); } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(/*visible*/ true); status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); } self.sync_status_inline_message(); self.request_redraw(); } } else { // Hide the status indicator when a task completes, but keep other modal views. self.hide_status_indicator(); } } pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) { self.composer.set_queue_submissions(queue_submissions); } /// Hide the status indicator while leaving task-running state untouched. pub(crate) fn hide_status_indicator(&mut self) { if self.status.take().is_some() { self.request_redraw(); } } pub(crate) fn ensure_status_indicator(&mut self) { if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( self.app_event_tx.clone(), self.frame_requester.clone(), self.animations_enabled, )); if let Some(status) = self.status.as_mut() { status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); } self.sync_status_inline_message(); self.request_redraw(); } } pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(visible); self.request_redraw(); } } pub(crate) fn set_context_window(&mut self, percent: Option<i64>, used_tokens: Option<i64>) { if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens { return; } self.context_window_percent = percent; self.context_window_used_tokens = used_tokens; self.composer .set_context_window(percent, self.context_window_used_tokens); self.request_redraw(); } /// Show a generic list selection view with the provided items. pub(crate) fn show_selection_view( &mut self, mut params: list_selection_view::SelectionViewParams, ) { self.apply_standard_popup_hint(&mut params); let view = list_selection_view::ListSelectionView::new( params, self.app_event_tx.clone(), self.keymap.list.clone(), ); self.push_view(Box::new(view)); } fn apply_standard_popup_hint(&self, params: &mut list_selection_view::SelectionViewParams) { if !params.allow_cancel { if params.footer_hint.is_none() || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) { params.footer_hint = None; } return; } if params.footer_hint.is_none() || params.footer_hint.as_ref() == Some(&popup_consts::standard_popup_hint_line()) { params.footer_hint = Some(self.standard_popup_hint_line()); } } /// Replace the active selection view when it matches `view_id`. pub(crate) fn replace_selection_view_if_active( &mut self, view_id: &'static str, mut params: list_selection_view::SelectionViewParams, ) -> bool { let is_match = self .view_stack .last() .is_some_and(|view| view.view_id() == Some(view_id)); if !is_match { return false; } self.view_stack.pop(); self.apply_standard_popup_hint(&mut params); let view = list_selection_view::ListSelectionView::new( params, self.app_event_tx.clone(), self.keymap.list.clone(), ); self.push_view(Box::new(view)); true } /// Replace the newest matching selection view without disturbing views stacked above it. pub(crate) fn replace_selection_view_if_present( &mut self, view_id: &'static str, mut params: list_selection_view::SelectionViewParams, ) -> bool { let Some(index) = self .view_stack .iter() .rposition(|view| view.view_id() == Some(view_id)) else { return false; }; let replaces_active_view = index + 1 == self.view_stack.len(); self.apply_standard_popup_hint(&mut params); self.view_stack[index] = Box::new(list_selection_view::ListSelectionView::new( params, self.app_event_tx.clone(), self.keymap.list.clone(), )); if replaces_active_view { self.schedule_active_view_frame(); } self.request_redraw(); true } pub(crate) fn standard_popup_hint_line(&self) -> Line<'static> { popup_consts::standard_popup_hint_line_for_keymap(&self.keymap.list) } pub(crate) fn list_keymap(&self) -> crate::keymap::ListKeymap { self.keymap.list.clone() } /// Replace one or more active views whose IDs are in `view_ids` with a /// generic list selection view. pub(crate) fn replace_active_views_with_selection_view( &mut self, view_ids: &[&'static str], mut params: list_selection_view::SelectionViewParams, ) -> bool { let is_match = self .view_stack .last() .and_then(|view| view.view_id()) .is_some_and(|view_id| view_ids.contains(&view_id)); if !is_match { return false; } while self .view_stack .last() .and_then(|view| view.view_id()) .is_some_and(|view_id| view_ids.contains(&view_id)) { self.view_stack.pop(); } self.apply_standard_popup_hint(&mut params); let view = list_selection_view::ListSelectionView::new( params, self.app_event_tx.clone(), self.keymap.list.clone(), ); self.push_view(Box::new(view)); true } pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option<usize> { self.view_stack .last() .filter(|view| view.view_id() == Some(view_id)) .and_then(|view| view.selected_index()) } pub(crate) fn active_tab_id_for_active_view(&self, view_id: &'static str) -> Option<&str> { self.view_stack .last() .filter(|view| view.view_id() == Some(view_id)) .and_then(|view| view.active_tab_id()) } pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool { let is_match = self .view_stack .last() .is_some_and(|view| view.view_id() == Some(view_id)); if !is_match { return false; } self.view_stack.pop(); self.request_redraw(); true } /// Dismiss the newest matching view without disturbing views stacked above it. pub(crate) fn dismiss_view_by_id(&mut self, view_id: &'static str) -> bool { let Some(index) = self .view_stack .iter() .rposition(|view| view.view_id() == Some(view_id)) else { return false; }; let removed_active_view = index + 1 == self.view_stack.len(); self.view_stack.remove(index); if removed_active_view { self.schedule_active_view_frame(); } self.request_redraw(); true } /// Update the pending-input preview shown above the composer. pub(crate) fn set_pending_input_preview( &mut self, queued: Vec<String>, pending_steers: Vec<String>, rejected_steers: Vec<String>, ) { self.pending_input_preview.pending_steers = pending_steers; self.pending_input_preview.rejected_steers = rejected_steers; self.pending_input_preview.queued_messages = queued; self.request_redraw(); } /// Update the inactive-thread approval list shown above the composer. pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) { if self.pending_thread_approvals.set_threads(threads) { self.request_redraw(); } } #[cfg(test)] pub(crate) fn pending_thread_approvals(&self) -> &[String] { self.pending_thread_approvals.threads() } /// Update the unified-exec process set and refresh whichever summary surface is active. /// /// The summary may be displayed inline in the status row or as a dedicated /// footer row depending on whether a status indicator is currently visible. pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec<String>) { if self.unified_exec_footer.set_processes(processes) { self.sync_status_inline_message(); self.request_redraw(); } } /// Copy unified-exec summary text into the active status row, if any. /// /// This keeps status-line inline text synchronized without forcing the /// standalone unified-exec footer row to be visible. fn sync_status_inline_message(&mut self) { if let Some(status) = self.status.as_mut() { status.update_inline_message(self.unified_exec_footer.summary_text()); } } pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } #[cfg(test)] pub(crate) fn composer_is_vim_enabled(&self) -> bool { self.composer.is_vim_enabled() } pub(crate) fn composer_should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { self.composer.should_handle_vim_insert_escape(key_event) } pub(crate) fn is_task_running(&self) -> bool { self.is_task_running } pub(crate) fn terminal_title_requires_action(&self) -> bool { self.active_view() .is_some_and(bottom_pane_view::BottomPaneView::terminal_title_requires_action) } pub(crate) fn has_active_view(&self) -> bool { !self.view_stack.is_empty() } #[cfg(test)] pub(crate) fn active_view_id(&self) -> Option<&'static str> { self.view_stack.last().and_then(|view| view.view_id()) } /// Return true when the pane is in the regular composer state without any /// overlays or popups and not running a task. This is the safe context to /// use Esc-Esc for backtracking from the main view. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } /// Return true when no popups or modal views are active, regardless of task state. pub(crate) fn can_launch_external_editor(&self) -> bool { self.view_stack.is_empty() && !self.composer.popup_active() } /// Returns true when the bottom pane has no active modal view and no active composer popup. /// /// This is the UI-level definition of "no modal/popup is active" for key routing decisions. /// It intentionally does not include task state, since some actions are safe while a task is /// running and some are not. pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.can_launch_external_editor() } pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) { self.push_view(view); } /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_approval_request(request) { Some(request) => request, None => { self.request_redraw(); return; } } } else { request }; let now = Instant::now(); if !self.delayed_approval_requests.is_empty() || self.approval_prompt_delay_remaining(now).is_some() { self.delayed_approval_requests .push_back(DelayedApprovalRequest { request, features: features.clone(), }); self.maybe_show_delayed_approval_requests_at(now); } else { // No recent composer activity, so show the approval modal immediately. let modal = ApprovalOverlay::new( request, self.app_event_tx.clone(), features.clone(), self.keymap.approval.clone(), self.keymap.list.clone(), ); self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } } /// Called when the agent requests user input. pub fn push_user_input_request(&mut self, request: ToolRequestUserInputParams) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_user_input_request(request) { Some(request) => request, None => { self.request_redraw(); return; } } } else { request }; let modal = RequestUserInputOverlay::new_with_keymap( request, self.app_event_tx.clone(), self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, self.keymap.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( /*enabled*/ false, Some("Answer the questions to continue.".to_string()), ); self.push_view(Box::new(modal)); } pub(crate) fn push_mcp_server_elicitation_request( &mut self, request: McpServerElicitationFormRequest, ) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_mcp_server_elicitation_request(request) { Some(request) => request, None => { self.request_redraw(); return; } } } else { request }; if let Some(tool_suggestion) = request.tool_suggestion() && let Some(install_url) = tool_suggestion.install_url.clone() { let suggestion_type = match tool_suggestion.suggest_type { mcp_server_elicitation::ToolSuggestionType::Install => { AppLinkSuggestionType::Install } mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable, }; let is_installed = matches!( tool_suggestion.suggest_type, mcp_server_elicitation::ToolSuggestionType::Enable ); let view = AppLinkView::new_with_keymap( AppLinkViewParams { app_id: tool_suggestion.tool_id.clone(), title: tool_suggestion.tool_name.clone(), description: None, instructions: match suggestion_type { AppLinkSuggestionType::Install => { "Install this app in your browser, then return here.".to_string() } AppLinkSuggestionType::Enable => { "Enable this app to use it for the current request.".to_string() } AppLinkSuggestionType::Auth => unreachable!( "auth uses URL mode elicitation, not tool suggestion forms" ), AppLinkSuggestionType::ExternalAction => unreachable!( "external actions use URL mode elicitation, not tool suggestion forms" ), }, url: install_url, is_installed, is_enabled: false, suggest_reason: Some(tool_suggestion.suggest_reason.clone()), suggestion_type: Some(suggestion_type), elicitation_target: Some(AppLinkElicitationTarget { thread_id: request.thread_id(), server_name: request.server_name().to_string(), request_id: request.request_id().clone(), }), }, self.app_event_tx.clone(), self.keymap.list.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( /*enabled*/ false, Some("Respond to the tool suggestion to continue.".to_string()), ); self.push_view(Box::new(view)); return; } let modal = McpServerElicitationOverlay::new_with_keymap( request, self.app_event_tx.clone(), self.has_input_focus, self.enhanced_keys_supported, self.disable_paste_burst, self.keymap.list.clone(), ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( /*enabled*/ false, Some("Respond to the MCP server request to continue.".to_string()), ); self.push_view(Box::new(modal)); } pub(crate) fn dismiss_app_server_request( &mut self, request: &ResolvedAppServerRequest, ) -> bool { let delayed_len = self.delayed_approval_requests.len(); self.delayed_approval_requests .retain(|delayed| !delayed.request.matches_resolved_request(request)); let delayed_changed = self.delayed_approval_requests.len() != delayed_len; if self.view_stack.is_empty() { if delayed_changed { self.request_redraw(); } return delayed_changed; } let mut changed = delayed_changed; let mut completed_indices = Vec::new(); for index in (0..self.view_stack.len()).rev() { let view = &mut self.view_stack[index]; if !view.dismiss_app_server_request(request) { continue; } changed = true; if view.is_complete() { completed_indices.push(index); } } if !changed { return false; } for index in completed_indices { self.view_stack.remove(index); } self.on_view_stack_depth_decreased(); self.request_redraw(); true } fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); self.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); } fn pause_status_timer_for_modal(&mut self) { if let Some(status) = self.status.as_mut() { status.pause_timer(); } } fn resume_status_timer_after_modal(&mut self) { if let Some(status) = self.status.as_mut() { status.resume_timer(); } } /// Height (terminal rows) required by the current bottom pane. pub(crate) fn request_redraw(&self) { self.frame_requester.schedule_frame(); } pub(crate) fn request_redraw_in(&self, dur: Duration) { self.frame_requester.schedule_frame_in(dur); } // --- History helpers --- pub(crate) fn set_history_metadata( &mut self, thread_id: ThreadId, log_id: u64, entry_count: usize, ) { self.thread_id = Some(thread_id); self.composer .set_history_metadata(thread_id, log_id, entry_count); } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { // Give the active view the first chance to flush paste-burst state so // overlays that reuse the composer behave consistently. if let Some(view) = self.view_stack.last_mut() && view.flush_paste_burst_if_due() { return true; } self.composer.flush_paste_burst_if_due() } pub(crate) fn is_in_paste_burst(&self) -> bool { // A view can hold paste-burst state independently of the primary // composer, so check it first. self.view_stack .last() .is_some_and(|view| view.is_in_paste_burst()) || self.composer.is_in_paste_burst() } pub(crate) fn on_history_entry_response( &mut self, log_id: u64, offset: usize, entry: Option<String>, ) { let updated = self .composer .on_history_entry_response(log_id, offset, entry); if updated { self.composer.sync_popups(); self.request_redraw(); } } pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) { self.composer.record_replayed_user_message_history(entry); } pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) { self.composer.on_file_search_result(query, matches); self.request_redraw(); } pub(crate) fn attach_image(&mut self, path: PathBuf) { if self.view_stack.is_empty() { self.composer.attach_image(path); self.request_redraw(); } } #[cfg(test)] pub(crate) fn take_recent_submission_images(&mut self) -> Vec<PathBuf> { self.composer.take_recent_submission_images() } pub(crate) fn take_recent_submission_images_with_placeholders( &mut self, ) -> Vec<LocalImageAttachment> { self.composer .take_recent_submission_images_with_placeholders() } pub(crate) fn prepare_inline_args_submission( &mut self, record_history: bool, ) -> Option<(String, Vec<TextElement>)> { self.composer.prepare_inline_args_submission(record_history) } fn as_renderable(&'_ self) -> RenderableItem<'_> { self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) } fn as_renderable_with_composer_right_reserve( &'_ self, composer_right_reserve: u16, ) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) } else { let mut flex = FlexRenderable::new(); if let Some(status) = &self.status { flex.push(/*flex*/ 0, RenderableItem::Borrowed(status)); } // Avoid double-surfacing the same summary and avoid adding an extra // row while the status line is already visible. if self.status.is_none() && !self.unified_exec_footer.is_empty() { flex.push( /*flex*/ 0, RenderableItem::Borrowed(&self.unified_exec_footer), ); } let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty(); let has_pending_input = !self.pending_input_preview.queued_messages.is_empty() || !self.pending_input_preview.pending_steers.is_empty() || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = self.status.is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; if has_inline_previews && has_status_or_footer { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); } flex.push( /*flex*/ 1, RenderableItem::Borrowed(&self.pending_thread_approvals), ); if has_pending_thread_approvals && has_pending_input { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); } flex.push( /*flex*/ 1, RenderableItem::Borrowed(&self.pending_input_preview), ); if !has_inline_previews && has_status_or_footer { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into())); let composer: RenderableItem<'_> = if composer_right_reserve == 0 { RenderableItem::Borrowed(&self.composer) } else { RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable { composer: &self.composer, right_reserve: composer_right_reserve, })) }; flex2.push(/*flex*/ 0, composer); RenderableItem::Owned(Box::new(flex2)) } } pub(crate) fn render_with_composer_right_reserve( &self, area: Rect, buf: &mut Buffer, composer_right_reserve: u16, ) { self.as_renderable_with_composer_right_reserve(composer_right_reserve) .render(area, buf); } pub(crate) fn desired_height_with_composer_right_reserve( &self, width: u16, composer_right_reserve: u16, ) -> u16 { self.as_renderable_with_composer_right_reserve(composer_right_reserve) .desired_height(width) } pub(crate) fn cursor_pos_with_composer_right_reserve( &self, area: Rect, composer_right_reserve: u16, ) -> Option<(u16, u16)> { self.as_renderable_with_composer_right_reserve(composer_right_reserve) .cursor_pos(area) } pub(crate) fn cursor_style_with_composer_right_reserve( &self, area: Rect, composer_right_reserve: u16, ) -> crossterm::cursor::SetCursorStyle { self.as_renderable_with_composer_right_reserve(composer_right_reserve) .cursor_style(area) } pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) { if self.composer.set_status_line(status_line) { self.request_redraw(); } } pub(crate) fn set_status_line_hyperlink(&mut self, url: Option<String>) { if self.composer.set_status_line_hyperlink(url) { self.request_redraw(); } } pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) { if self.composer.set_status_line_enabled(enabled) { self.request_redraw(); } } /// Updates the contextual footer label and requests a redraw only when it changed. /// /// This keeps the footer plumbing cheap during thread transitions where `App` may recompute /// the label several times while the visible thread settles. pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option<String>) { if self.composer.set_active_agent_label(active_agent_label) { self.request_redraw(); } } pub(crate) fn set_side_conversation_context_label(&mut self, label: Option<String>) { if self.composer.set_side_conversation_context_label(label) { self.request_redraw(); } }}struct ChatComposerRightReserveRenderable<'a> { composer: &'a chat_composer::ChatComposer, right_reserve: u16,}impl Renderable for ChatComposerRightReserveRenderable<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { self.composer.render_with_mask_and_textarea_right_reserve( area, buf, /*mask_char*/ None, self.right_reserve, ); } fn desired_height(&self, width: u16) -> u16 { self.composer .desired_height_with_textarea_right_reserve(width, self.right_reserve) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.composer .cursor_pos_with_textarea_right_reserve(area, self.right_reserve) } fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { self.composer.cursor_style(area) }}impl Renderable for BottomPane { fn render(&self, area: Rect, buf: &mut Buffer) { self.as_renderable().render(area, buf); } fn desired_height(&self, width: u16) -> u16 { self.as_renderable().desired_height(width) } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { self.as_renderable().cursor_style(area) }}#[cfg(test)]mod tests { use super::*; use crate::app::app_server_requests::ResolvedAppServerRequest; use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use codex_app_server_protocol::CommandExecutionApprovalDecision; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::cell::Cell; use std::rc::Rc; use std::time::Instant; use tokio::sync::mpsc::unbounded_channel; fn snapshot_buffer(buf: &Buffer) -> String { let mut lines = Vec::new(); for y in 0..buf.area().height { let mut row = String::new(); for x in 0..buf.area().width { row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } lines.push(row); } lines.join("\n") } fn render_snapshot(pane: &BottomPane, area: Rect) -> String { let mut buf = Buffer::empty(area); pane.render(area, &mut buf); snapshot_buffer(&buf) } fn test_pane(app_event_tx: AppEventSender) -> BottomPane { test_pane_with_disable_paste_burst(app_event_tx, /*disable_paste_burst*/ false) } fn test_pane_with_disable_paste_burst( app_event_tx: AppEventSender, disable_paste_burst: bool, ) -> BottomPane { BottomPane::new(BottomPaneParams { app_event_tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst, animations_enabled: true, skills: Some(Vec::new()), }) } fn exec_request() -> ApprovalRequest { ApprovalRequest::Exec { thread_id: codex_protocol::ThreadId::new(), thread_label: None, id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, available_decisions: vec![ CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::Cancel, ], network_approval_context: None, additional_permissions: None, } } #[derive(Default)] struct DismissibleView { id: Option<&'static str>, dismiss_exec_id: Option<&'static str>, complete: bool, } impl Renderable for DismissibleView { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { 0 } } impl BottomPaneView for DismissibleView { fn is_complete(&self) -> bool { self.complete } fn view_id(&self) -> Option<&'static str> { self.id } fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { let ResolvedAppServerRequest::ExecApproval { id } = request else { return false; }; if self.dismiss_exec_id != Some(id.as_str()) { return false; } self.complete = true; true } } #[derive(Default)] struct CompletingView { id: Option<&'static str>, complete: bool, } impl Renderable for CompletingView { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { 0 } } impl BottomPaneView for CompletingView { fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.code == KeyCode::Enter { self.complete = true; } } fn is_complete(&self) -> bool { self.complete } fn view_id(&self) -> Option<&'static str> { self.id } } #[test] fn ctrl_c_on_modal_consumes_without_showing_quit_hint() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: true, animations_enabled: true, skills: Some(Vec::new()), }); pane.push_approval_request(exec_request(), &features); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); assert!(!pane.quit_shortcut_hint_visible()); assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); } #[test] fn ctrl_c_cancels_history_search_without_clearing_draft_or_showing_quit_hint() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: true, animations_enabled: true, skills: Some(Vec::new()), }); pane.insert_str("draft"); pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(pane.composer.popup_active()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); assert_eq!(pane.composer_text(), "draft"); assert!(!pane.composer.popup_active()); assert!(!pane.quit_shortcut_hint_visible()); } // live ring removed; related tests deleted. #[test] fn overlay_not_shown_above_approval_modal() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); // Create an approval modal (active view). pane.push_approval_request(exec_request(), &features); // Render and verify the top row does not include an overlay. let area = Rect::new(0, 0, 60, 6); let mut buf = Buffer::empty(area); pane.render(area, &mut buf); let mut r0 = String::new(); for x in 0..area.width { r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); } assert!( !r0.contains("Working"), "overlay should not render above modal" ); } #[test] fn approval_request_shows_immediately_without_recent_typing() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = test_pane(tx); pane.push_approval_request(exec_request(), &features); assert_eq!(pane.view_stack.len(), 1); assert!(pane.delayed_approval_requests.is_empty()); } #[test] fn approval_request_is_delayed_after_recent_typing() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = test_pane(tx); let now = Instant::now(); pane.last_composer_activity_at = Some(now); pane.push_approval_request(exec_request(), &features); assert!(pane.view_stack.is_empty()); assert_eq!(pane.delayed_approval_requests.len(), 1); pane.pre_draw_tick_at( now + APPROVAL_PROMPT_TYPING_IDLE_DELAY - Duration::from_millis(/*millis*/ 1), ); assert!(pane.view_stack.is_empty()); assert_eq!(pane.delayed_approval_requests.len(), 1); pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY); assert_eq!(pane.view_stack.len(), 1); assert!(pane.delayed_approval_requests.is_empty()); } #[test] fn continued_typing_resets_delayed_approval_idle_deadline() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = test_pane(tx); let first_activity = Instant::now(); pane.last_composer_activity_at = Some(first_activity); pane.push_approval_request(exec_request(), &features); let continued_activity = first_activity + Duration::from_millis(/*millis*/ 750); pane.record_composer_activity_at(continued_activity); pane.pre_draw_tick_at(first_activity + APPROVAL_PROMPT_TYPING_IDLE_DELAY); assert!(pane.view_stack.is_empty()); assert_eq!(pane.delayed_approval_requests.len(), 1); pane.pre_draw_tick_at(continued_activity + APPROVAL_PROMPT_TYPING_IDLE_DELAY); assert_eq!(pane.view_stack.len(), 1); assert!(pane.delayed_approval_requests.is_empty()); } #[test] fn typed_approval_shortcuts_during_delay_stay_in_composer() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = test_pane_with_disable_paste_burst(tx, /*disable_paste_burst*/ true); pane.last_composer_activity_at = Some(Instant::now()); pane.push_approval_request(exec_request(), &features); pane.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); pane.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); assert_eq!(pane.composer_text(), "ya"); assert!(pane.view_stack.is_empty()); assert_eq!(pane.delayed_approval_requests.len(), 1); while let Ok(event) = rx.try_recv() { assert!( !matches!(event, AppEvent::SubmitThreadOp { .. }), "delayed approval shortcut should not submit an approval: {event:?}" ); } } #[test] fn delayed_approval_shortcut_works_after_idle_deadline() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = test_pane(tx); let now = Instant::now(); pane.last_composer_activity_at = Some(now); pane.push_approval_request(exec_request(), &features); pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY); pane.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); let mut approval_decision = None; while let Ok(event) = rx.try_recv() { if let AppEvent::SubmitThreadOp { op: Op::ExecApproval { decision, .. }, .. } = event { approval_decision = Some(decision); } } assert_eq!( approval_decision, Some(CommandExecutionApprovalDecision::Accept) ); } #[test] fn dismiss_app_server_request_prunes_delayed_approval() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = test_pane(tx); let now = Instant::now(); pane.last_composer_activity_at = Some(now); pane.push_approval_request(exec_request(), &features); assert!( pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { id: "1".to_string(), }) ); assert!(pane.delayed_approval_requests.is_empty()); pane.pre_draw_tick_at(now + APPROVAL_PROMPT_TYPING_IDLE_DELAY); assert!(pane.view_stack.is_empty()); } #[test] fn dismiss_app_server_request_removes_matching_buried_view() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = test_pane(tx); pane.push_view(Box::new(DismissibleView { id: Some("buried"), dismiss_exec_id: Some("request-1"), complete: false, })); pane.push_view(Box::new(DismissibleView { id: Some("top"), dismiss_exec_id: None, complete: false, })); assert!( pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { id: "request-1".to_string(), }) ); assert_eq!(pane.view_stack.len(), 1); assert_eq!( pane.view_stack.last().and_then(|view| view.view_id()), Some("top") ); } #[test] fn dismiss_app_server_request_returns_false_when_no_view_matches() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = test_pane(tx); pane.push_view(Box::new(DismissibleView { id: Some("first"), dismiss_exec_id: Some("other-request"), complete: false, })); pane.push_view(Box::new(DismissibleView { id: Some("second"), dismiss_exec_id: None, complete: false, })); assert!( !pane.dismiss_app_server_request(&ResolvedAppServerRequest::ExecApproval { id: "request-1".to_string(), }) ); assert_eq!(pane.view_stack.len(), 2); assert_eq!( pane.view_stack.last().and_then(|view| view.view_id()), Some("second") ); } #[test] fn completing_top_view_preserves_underlying_view() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = test_pane(tx); pane.push_view(Box::new(DismissibleView { id: Some("underlying"), dismiss_exec_id: None, complete: false, })); pane.push_view(Box::new(CompletingView { id: Some("top"), complete: false, })); pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(pane.view_stack.len(), 1); assert_eq!( pane.view_stack.last().and_then(|view| view.view_id()), Some("underlying") ); } #[test] fn composer_shown_after_denied_while_task_running() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); // Start a running task so the status indicator is active above the composer. pane.set_task_running(/*running*/ true); // Push an approval modal (e.g., command approval) which should hide the status view. pane.push_approval_request(exec_request(), &features); // Simulate pressing 'n' (No) on the modal. use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); // After denial, since the task is still running, the status indicator should be // visible above the composer. The modal should be gone. assert!( pane.view_stack.is_empty(), "no active modal view after denial" ); // Render and ensure the top row includes the Working header and a composer line below. // Give the animation thread a moment to tick. std::thread::sleep(Duration::from_millis(120)); let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); pane.render(area, &mut buf); let mut row0 = String::new(); for x in 0..area.width { row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); } assert!( row0.contains("Working"), "expected Working header after denial on row 0: {row0:?}" ); // Composer placeholder should be visible somewhere below. let mut found_composer = false; for y in 1..area.height { let mut row = String::new(); for x in 0..area.width { row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } if row.contains("Ask Codex") { found_composer = true; break; } } assert!( found_composer, "expected composer visible under status line" ); } #[test] fn status_indicator_visible_during_command_execution() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); // Begin a task: show initial status. pane.set_task_running(/*running*/ true); // Use a height that allows the status line to be visible above the composer. let area = Rect::new(0, 0, 40, 6); let mut buf = Buffer::empty(area); pane.render(area, &mut buf); let bufs = snapshot_buffer(&buf); assert!(bufs.contains("• Working"), "expected Working header"); } #[test] fn status_and_composer_fill_height_without_bottom_padding() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); // Activate spinner (status view replaces composer) with no live ring. pane.set_task_running(/*running*/ true); // Use height == desired_height; expect spacer + status + composer rows without trailing padding. let height = pane.desired_height(/*width*/ 30); assert!( height >= 3, "expected at least 3 rows to render spacer, status, and composer; got {height}" ); let area = Rect::new(0, 0, 30, height); assert_snapshot!( "status_and_composer_fill_height_without_bottom_padding", render_snapshot(&pane, area) ); } #[test] fn status_only_snapshot() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); let width = 48; let height = pane.desired_height(width); let area = Rect::new(0, 0, width, height); assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); } #[test] fn unified_exec_summary_does_not_increase_height_when_status_visible() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); let width = 120; let before = pane.desired_height(width); pane.set_unified_exec_processes(vec!["sleep 5".to_string()]); let after = pane.desired_height(width); assert_eq!(after, before); let area = Rect::new(0, 0, width, after); let rendered = render_snapshot(&pane, area); assert!(rendered.contains("background terminal running · /ps to view")); } #[test] fn status_with_details_and_queued_messages_snapshot() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); pane.update_status( "Working".to_string(), Some("First detail line\nSecond detail line".to_string()), StatusDetailsCapitalization::CapitalizeFirst, STATUS_DETAILS_DEFAULT_MAX_LINES, ); pane.set_pending_input_preview( vec!["Queued follow-up question".to_string()], Vec::new(), Vec::new(), ); let width = 48; let height = pane.desired_height(width); let area = Rect::new(0, 0, width, height); assert_snapshot!( "status_with_details_and_queued_messages_snapshot", render_snapshot(&pane, area) ); } #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); pane.set_pending_input_preview( vec!["Queued follow-up question".to_string()], Vec::new(), Vec::new(), ); pane.hide_status_indicator(); let width = 48; let height = pane.desired_height(width); let area = Rect::new(0, 0, width, height); assert_snapshot!( "queued_messages_visible_when_status_hidden_snapshot", render_snapshot(&pane, area) ); } #[test] fn status_and_queued_messages_snapshot() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); pane.set_pending_input_preview( vec!["Queued follow-up question".to_string()], Vec::new(), Vec::new(), ); let width = 48; let height = pane.desired_height(width); let area = Rect::new(0, 0, width, height); assert_snapshot!( "status_and_queued_messages_snapshot", render_snapshot(&pane, area) ); } #[test] fn remote_images_render_above_composer_text() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_remote_image_urls(vec![ "https://example.com/one.png".to_string(), "data:image/png;base64,aGVsbG8=".to_string(), ]); assert_eq!(pane.composer_text(), ""); let width = 48; let height = pane.desired_height(width); let area = Rect::new(0, 0, width, height); let snapshot = render_snapshot(&pane, area); assert!(snapshot.contains("[Image #1]")); assert!(snapshot.contains("[Image #2]")); } #[test] fn drain_pending_submission_state_clears_remote_image_urls() { let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]); assert_eq!(pane.remote_image_urls().len(), 1); pane.drain_pending_submission_state(); assert!(pane.remote_image_urls().is_empty()); } #[test] fn esc_with_skill_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(vec![SkillMetadata { name: "test-skill".to_string(), description: "test skill".to_string(), short_description: None, interface: None, dependencies: None, policy: None, path_to_skills_md: test_path_buf("/tmp/test-skill/SKILL.md").abs(), scope: crate::test_support::skill_scope_user(), plugin_id: None, }]), }); pane.set_task_running(/*running*/ true); // Repro: a running task + skill popup + Esc should dismiss the popup, not interrupt. pane.insert_str("$"); assert!( pane.composer.popup_active(), "expected skill popup after typing `$`" ); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); while let Ok(ev) = rx.try_recv() { assert!( !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), "expected Esc to not send Op::Interrupt when dismissing skill popup" ); } assert!( !pane.composer.popup_active(), "expected Esc to dismiss skill popup" ); } #[test] fn esc_with_slash_command_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); // Repro: a running task + slash-command popup + Esc should not interrupt the task. pane.insert_str("/"); assert!( pane.composer.popup_active(), "expected command popup after typing `/`" ); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); while let Ok(ev) = rx.try_recv() { assert!( !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), "expected Esc to not send Op::Interrupt while command popup is active" ); } assert_eq!(pane.composer_text(), "/"); } #[test] fn esc_with_agent_command_without_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); // Repro: `/agent ` hides the popup (cursor past command name). Esc should // keep editing command text instead of interrupting the running task. pane.insert_str("/agent "); assert!( !pane.composer.popup_active(), "expected command popup to be hidden after entering `/agent `" ); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); while let Ok(ev) = rx.try_recv() { assert!( !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), "expected Esc to not send Op::Interrupt while typing `/agent`" ); } assert_eq!(pane.composer_text(), "/agent "); } #[test] fn esc_release_after_dismissing_agent_picker_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); pane.show_selection_view(SelectionViewParams { title: Some("Agents".to_string()), items: vec![SelectionItem { name: "Main".to_string(), ..Default::default() }], ..Default::default() }); pane.handle_key_event(KeyEvent::new_with_kind( KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Press, )); pane.handle_key_event(KeyEvent::new_with_kind( KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Release, )); while let Ok(ev) = rx.try_recv() { assert!( !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), "expected Esc release after dismissing agent picker to not interrupt" ); } assert!( pane.no_modal_or_popup_active(), "expected Esc press to dismiss the agent picker" ); } #[test] fn esc_interrupts_running_task_when_no_popup() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_task_running(/*running*/ true); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))), "expected Esc to send Op::Interrupt while a task is running" ); } #[test] fn remapped_interrupt_turn_uses_configured_key_including_agent_drafts() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = test_pane(tx); let mut keymap = RuntimeKeymap::defaults(); keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; pane.set_keymap_bindings(&keymap); pane.set_task_running(/*running*/ true); pane.insert_str("/agent "); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( rx.try_recv().is_err(), "expected Esc to remain local after remapping interruption" ); pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); assert!( matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))), "expected configured key to interrupt while `/agent` is being edited" ); } #[test] fn selection_view_esc_respects_remapped_list_cancel() { let (tx_raw, mut rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = test_pane(tx); let mut keymap = RuntimeKeymap::defaults(); keymap.list.cancel = vec![crate::key_hint::plain(KeyCode::Char('q'))]; pane.set_keymap_bindings(&keymap); pane.show_selection_view(SelectionViewParams { title: Some("Agents".to_string()), items: vec![SelectionItem { name: "Main".to_string(), ..Default::default() }], on_cancel: Some(Box::new(|tx: &_| { tx.send(AppEvent::OpenApprovalsPopup); })), ..Default::default() }); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(pane.active_view().is_some()); assert!(rx.try_recv().is_err()); pane.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)); assert!(pane.no_modal_or_popup_active()); assert!(matches!(rx.try_recv(), Ok(AppEvent::OpenApprovalsPopup))); } #[test] fn esc_routes_to_handle_key_event_when_requested() { #[derive(Default)] struct EscRoutingView { on_ctrl_c_calls: Rc<Cell<usize>>, handle_calls: Rc<Cell<usize>>, } impl Renderable for EscRoutingView { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { 0 } } impl BottomPaneView for EscRoutingView { fn handle_key_event(&mut self, _key_event: KeyEvent) { self.handle_calls .set(self.handle_calls.get().saturating_add(1)); } fn on_ctrl_c(&mut self) -> CancellationEvent { self.on_ctrl_c_calls .set(self.on_ctrl_c_calls.get().saturating_add(1)); CancellationEvent::Handled } fn prefer_esc_to_handle_key_event(&self) -> bool { true } } let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); let on_ctrl_c_calls = Rc::new(Cell::new(0)); let handle_calls = Rc::new(Cell::new(0)); pane.push_view(Box::new(EscRoutingView { on_ctrl_c_calls: Rc::clone(&on_ctrl_c_calls), handle_calls: Rc::clone(&handle_calls), })); pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(on_ctrl_c_calls.get(), 0); assert_eq!(handle_calls.get(), 1); } #[test] fn release_events_are_ignored_for_active_view() { #[derive(Default)] struct CountingView { handle_calls: Rc<Cell<usize>>, } impl Renderable for CountingView { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { 0 } } impl BottomPaneView for CountingView { fn handle_key_event(&mut self, _key_event: KeyEvent) { self.handle_calls .set(self.handle_calls.get().saturating_add(1)); } } let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); let handle_calls = Rc::new(Cell::new(0)); pane.push_view(Box::new(CountingView { handle_calls: Rc::clone(&handle_calls), })); pane.handle_key_event(KeyEvent::new_with_kind( KeyCode::Down, KeyModifiers::NONE, KeyEventKind::Press, )); pane.handle_key_event(KeyEvent::new_with_kind( KeyCode::Down, KeyModifiers::NONE, KeyEventKind::Release, )); assert_eq!(handle_calls.get(), 1); } #[test] fn paste_completion_clears_stacked_views_and_restores_composer_input() { #[derive(Default)] struct BlockingView { handle_calls: Rc<Cell<usize>>, } impl Renderable for BlockingView { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { 0 } } impl BottomPaneView for BlockingView { fn handle_key_event(&mut self, _key_event: KeyEvent) { self.handle_calls .set(self.handle_calls.get().saturating_add(1)); } } #[derive(Default)] struct PasteCompletesView { complete: bool, } impl Renderable for PasteCompletesView { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { 0 } } impl BottomPaneView for PasteCompletesView { fn handle_paste(&mut self, _pasted: String) -> bool { self.complete = true; true } fn is_complete(&self) -> bool { self.complete } } let (tx_raw, _rx) = unbounded_channel::<AppEvent>(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, skills: Some(Vec::new()), }); pane.set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None); let lower_view_handle_calls = Rc::new(Cell::new(0)); pane.push_view(Box::new(BlockingView { handle_calls: Rc::clone(&lower_view_handle_calls), })); pane.push_view(Box::new(PasteCompletesView::default())); pane.handle_paste("hello".to_string()); assert!( pane.view_stack.is_empty(), "paste completion should tear down the active modal flow" ); pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); let area = Rect::new(0, 0, 40, pane.desired_height(/*width*/ 40).max(2)); assert!(pane.cursor_pos(area).is_some()); assert_eq!(lower_view_handle_calls.get(), 0); }}