use std::collections::VecDeque;
use std::path::PathBuf;
use crate::tui_internal::app::app_server_requests::ResolvedAppServerRequest;
use crate::tui_internal::app_event::AppEvent;
use crate::tui_internal::app_event::ConnectorsSnapshot;
use crate::tui_internal::app_event::HistoryLookupResponse;
use crate::tui_internal::app_event_sender::AppEventSender;
use crate::tui_internal::bottom_pane::pending_input_preview::PendingInputPreview;
use crate::tui_internal::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
use crate::tui_internal::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::tui_internal::key_hint;
use crate::tui_internal::key_hint::KeyBinding;
use crate::tui_internal::key_hint::KeyBindingListExt;
use crate::tui_internal::keymap::RuntimeKeymap;
use crate::tui_internal::keymap::primary_binding;
use crate::tui_internal::render::renderable::FlexRenderable;
use crate::tui_internal::render::renderable::Renderable;
use crate::tui_internal::render::renderable::RenderableItem;
use crate::tui_internal::tui::FrameRequester;
pub(crate) use bottom_pane_view::BottomPaneView;
pub(crate) use bottom_pane_view::ViewCompletion;
use lemurclaw_core::app_server_protocol::ToolRequestUserInputParams;
use lemurclaw_core::core_skills::model::SkillMetadata;
use lemurclaw_core::features::Features;
use lemurclaw_core::file_search::FileMatch;
use lemurclaw_core::plugin::PluginCapabilitySummary;
use lemurclaw_core::protocol::ThreadId;
use lemurclaw_core::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::ApplyPatchApprovalRequest;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
pub(crate) use approval_overlay::ExecApprovalRequest;
pub(crate) use approval_overlay::McpElicitationApprovalRequest;
pub(crate) use approval_overlay::PermissionsApprovalRequest;
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 {
pub(crate) sigil: char,
pub(crate) mention: String,
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;
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
const APPROVAL_PROMPT_TYPING_IDLE_DELAY: Duration = Duration::from_secs(1);
pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Handled,
NotHandled,
}
use crate::tui_internal::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::tui_internal::status_indicator_widget::StatusDetailsCapitalization;
use crate::tui_internal::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,
}
pub(crate) struct BottomPane {
composer: ChatComposer,
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,
status: Option<StatusIndicatorWidget>,
unified_exec_footer: UnifiedExecFooter,
pending_input_preview: PendingInputPreview,
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();
}
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()
}
pub(crate) fn record_pending_slash_command_history(&mut self) {
self.composer.record_pending_slash_command_history();
}
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();
}
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();
}
pub(crate) fn set_parent_owned_thread(&mut self) {
self.composer.set_parent_owned_thread();
self.request_redraw();
}
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;
}
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));
}
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
if !self.view_stack.is_empty() {
if key_event.kind == KeyEventKind::Release {
return InputResult::None;
}
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 self.should_interrupt_running_task(key_event)
&& let Some(status) = &self.status
{
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
}
}
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);
}
}
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();
}
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();
}
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()
}
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();
}
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();
}
pub(crate) fn update_status(
&mut self,
header: String,
details: Option<String>,
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) -> bool {
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();
return true;
}
false
}
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 {
std::thread::spawn(move || {
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
frame_requester.schedule_frame();
});
}
self.request_redraw();
}
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( 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( false);
self.request_redraw();
}
}
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( true);
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
}
self.sync_status_inline_message();
self.request_redraw();
}
} else {
self.hide_status_indicator();
}
}
pub(crate) fn set_queue_submissions(&mut self, queue_submissions: bool) {
self.composer.set_queue_submissions(queue_submissions);
}
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();
}
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());
}
}
pub(crate) fn replace_selection_view_if_active(
&mut self,
view_id: &'static str,
mut params: list_selection_view::SelectionViewParams,
) -> bool {
let is_match = self
.view_stack
.last()
.is_some_and(|view| view.view_id() == Some(view_id));
if !is_match {
return false;
}
self.view_stack.pop();
self.apply_standard_popup_hint(&mut params);
let view = list_selection_view::ListSelectionView::new(
params,
self.app_event_tx.clone(),
self.keymap.list.clone(),
);
self.push_view(Box::new(view));
true
}
pub(crate) fn 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::tui_internal::keymap::ListKeymap {
self.keymap.list.clone()
}
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
}
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
}
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();
}
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()
}
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();
}
}
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())
}
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
}
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.view_stack.is_empty() && !self.composer.popup_active()
}
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);
}
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 {
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));
}
}
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(
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(
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(
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( true, 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();
}
}
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);
}
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 {
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 {
self.view_stack
.last()
.is_some_and(|view| view.is_in_paste_burst())
|| self.composer.is_in_paste_burst()
}
pub(crate) fn on_history_lookup_response(&mut self, response: HistoryLookupResponse) {
let updated = match response {
HistoryLookupResponse::Entry {
offset,
log_id,
entry,
} => self
.composer
.on_history_entry_response(log_id, offset, entry),
HistoryLookupResponse::Batch {
cursor,
log_id,
entries,
next_older_cursor,
} => {
self.composer
.on_history_batch_response(log_id, cursor, entries, next_older_cursor)
}
HistoryLookupResponse::BatchError { cursor, log_id } => {
self.composer.on_history_batch_error(log_id, cursor)
}
};
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( 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( 0, RenderableItem::Borrowed(status));
}
if self.status.is_none() && !self.unified_exec_footer.is_empty() {
flex.push(
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( 0, RenderableItem::Owned("".into()));
}
flex.push(
1,
RenderableItem::Borrowed(&self.pending_thread_approvals),
);
if has_pending_thread_approvals && has_pending_input {
flex.push( 0, RenderableItem::Owned("".into()));
}
flex.push(
1,
RenderableItem::Borrowed(&self.pending_input_preview),
);
if !has_inline_previews && has_status_or_footer {
flex.push( 0, RenderableItem::Owned("".into()));
}
let mut flex2 = FlexRenderable::new();
flex2.push( 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( 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();
}
}
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,
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::tui_internal::app::app_server_requests::ResolvedAppServerRequest;
use crate::tui_internal::app_command::AppCommand as Op;
use crate::tui_internal::app_event::AppEvent;
use crate::tui_internal::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::tui_internal::status_indicator_widget::StatusDetailsCapitalization;
use crate::tui_internal::test_support::PathBufExt;
use crate::tui_internal::test_support::test_path_buf;
use lemurclaw_core::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, 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(ExecApprovalRequest {
thread_id: lemurclaw_core::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());
}
#[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()),
});
pane.push_approval_request(exec_request(), &features);
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( 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( 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, 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()),
});
pane.set_task_running( true);
pane.push_approval_request(exec_request(), &features);
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
assert!(
pane.view_stack.is_empty(),
"no active modal view after denial"
);
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:?}"
);
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()),
});
pane.set_task_running( true);
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()),
});
pane.set_task_running( true);
let height = pane.desired_height( 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( 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( 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( 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( 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( 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::tui_internal::test_support::skill_scope_user(),
plugin_id: None,
}]),
});
pane.set_task_running( true);
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_dismisses_slash_command_popup_without_interrupting_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( true);
pane.insert_str("/rev");
assert!(
pane.composer.popup_active(),
"expected command popup after typing `/rev`"
);
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!(!pane.composer.popup_active());
assert_eq!(pane.composer_text(), "/rev");
let width = 60;
let area = Rect::new(0, 0, width, pane.desired_height(width));
assert_snapshot!(
"slash_command_popup_dismissed",
render_snapshot(&pane, area)
);
pane.insert_str("i");
assert!(pane.composer.popup_active());
}
#[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( true);
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( 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( 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::tui_internal::key_hint::plain(KeyCode::F(12))];
pane.set_keymap_bindings(&keymap);
pane.set_task_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::tui_internal::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( false, 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( 40).max(2));
assert!(pane.cursor_pos(area).is_some());
assert_eq!(lower_view_handle_calls.get(), 0);
}
}