Files
codex/codex-rs/tui/src/bottom_pane/mod.rs
T
Felipe Coury 679a944dbc fix(tui): restore cancelled prompt cursor at end (#26457)
## Why

Pressing `Esc` on a turn that produced no visible output restores the
submitted prompt so the user can keep editing it. That restore path
preserved the prompt content, images, and mention bindings, but left the
composer cursor at the start of the restored text. The next edit
therefore inserted at the beginning instead of continuing from the end
of the prompt.

## What Changed

- Move the cursor to the end after
`BottomPane::set_composer_text_with_mention_bindings` rehydrates a
restored draft.
- Add test-only cursor accessors so restore tests can assert the
composer state directly.
- Extend the queued restore regression to assert the restored composer
cursor is positioned at `text.len()`.

## How to Test

Manual reviewer flow:

1. Start Codex in the TUI.
2. Submit a prompt that will take long enough to interrupt.
3. Press `Esc` before any visible assistant output appears.
4. Confirm the prompt is restored into the composer and the cursor is at
the end, so typing appends to the prompt.
5. Repeat with a prompt that includes an attached image or resolved
mention and confirm the restored content remains intact.

Targeted tests:

- `just test -p codex-tui
chatwidget::tests::composer_submission::queued_restore_with_remote_images_keeps_local_placeholder_mapping`

Lint note:

- `just argument-comment-lint` is blocked locally by the existing Bazel
`compiler-rt` empty glob failure before analyzing touched code. The
touched Rust diff was manually inspected and adds no new opaque
positional literal callsites.
2026-06-05 15:10:13 -03:00

3007 lines
107 KiB
Rust

//! 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_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 fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
self.composer.set_realtime_conversation_enabled(enabled);
self.request_redraw();
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.composer.set_audio_device_selection_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.schedule_active_view_frame();
}
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()
}
#[cfg(test)]
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();
}
#[cfg(test)]
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.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
}
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
}
/// 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)
}
}
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
let id = self.composer.insert_recording_meter_placeholder(text);
self.composer.sync_popups();
self.request_redraw();
id
}
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_recording_meter_in_place(id, text);
if updated {
self.composer.sync_popups();
self.request_redraw();
}
updated
}
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
self.composer.remove_recording_meter_placeholder(id);
self.composer.sync_popups();
self.request_redraw();
}
}
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);
}
}