mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
a0d5fd772e
This is the final plugin sharing PR in the 5-PR stack. It applies the remaining TUI polish for remote plugin catalog rows and tabs: admin-disabled plugins now read as blocked/view-only instead of looking toggleable, admin-installed/default-installed plugins count and sort like installed plugins, plugin search matches richer metadata, and an empty successful `Shared with me` section stays hidden. - Admin-disabled rows use a blocked marker, show `Disabled`, and keep Enter-only detail behavior without a toggle hint. - Admin-installed/default-installed plugins show as installed in counts, ordering, tabs, and detail copy. - Plugin search now matches descriptions and keywords in addition to existing row metadata. - Successful-empty `Shared with me` tabs are hidden, while loading, error, workspace-empty, and real shared-plugin states remain visible. - Updates coverage in `plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name`, `plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint`, `plugins_popup_search_matches_plugin_descriptions`, and `plugins_popup_remote_section_fallback_states_snapshot`. - Updates snapshots `plugins_popup_curated_marketplace` and `plugins_popup_empty_shared_section_hidden`. <img width="2034" height="106" alt="image" src="https://github.com/user-attachments/assets/3f9a57e1-edd8-4e6c-b0b0-9f632a3c9529" /> <img width="2038" height="380" alt="image" src="https://github.com/user-attachments/assets/45a47491-3381-4846-a13d-496bc0051d42" />
3061 lines
109 KiB
Rust
3061 lines
109 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::SELECTION_TOGGLE_BLOCKED_PREFIX;
|
|
pub(crate) use list_selection_view::SELECTION_TOGGLE_UNAVAILABLE_PREFIX;
|
|
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 {
|
|
// 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.should_interrupt_running_task(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 should_interrupt_running_task(&self, key_event: KeyEvent) -> bool {
|
|
let is_agent_command = self
|
|
.composer_text()
|
|
.lines()
|
|
.next()
|
|
.and_then(parse_slash_name)
|
|
.is_some_and(|(name, _, _)| name == "agent");
|
|
|
|
self.keymap.chat.interrupt_turn.is_pressed(key_event)
|
|
&& self.is_task_running
|
|
&& !(is_agent_command && key_event.code == KeyCode::Esc)
|
|
&& self.no_modal_or_popup_active()
|
|
&& !self.composer_should_handle_vim_insert_escape(key_event)
|
|
&& self.status.is_some()
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
pub(crate) fn active_view_will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool {
|
|
self.is_task_running
|
|
&& self
|
|
.active_view()
|
|
.is_some_and(|view| view.will_interrupt_turn_on_key_event(key_event))
|
|
}
|
|
|
|
#[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(),
|
|
environment_id: None,
|
|
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);
|
|
}
|
|
}
|