From 3c3e18c2227888d941acc4dffa60abaf5a164ff3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 13 May 2026 15:40:53 -0700 Subject: [PATCH] Refactor chatwidget orchestration into modules (phase 5) (#22537) ## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow, #22433 completed phase 3 by extracting protocol, replay, streaming, and tool lifecycle handling, and #22518 completed phase 4 by extracting settings, popups, and status surfaces. This PR is phase 5. It cleans up the remaining constructor and orchestration code now that the larger behavior domains have moved out, leaving `chatwidget.rs` much closer to the composition layer the cleanup was aiming for. This is once again a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for widget construction and initial wiring, session configuration flow, key/composer interaction routing, review popup orchestration, desktop notification coalescing, and render composition. - Moved the remaining constructor, session setup, interaction, notification, review picker, and rendering helpers out of `codex-rs/tui/src/chatwidget.rs`. - Preserved the existing startup/session behavior, keyboard handling, review picker flow, notification priority behavior, and render composition while shrinking the central widget module substantially. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and composition surface for the extracted behavior modules. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. Completed in #22433. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. Completed in #22518. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer. This PR. ## Verification - `cargo check -p codex-tui` - `cargo test -p codex-tui chatwidget::tests::popups_and_settings` - `cargo test -p codex-tui chatwidget::tests::plan_mode` - `cargo test -p codex-tui chatwidget::tests::review_mode` - `cargo test -p codex-tui chatwidget::tests::status_and_layout` `cargo test -p codex-tui` also compiles and begins running, but aborts in the unchanged app-side test `app::tests::discard_side_thread_keeps_local_state_when_server_close_fails` with the same reproducible stack overflow noted in phase 4. --- codex-rs/tui/src/chatwidget.rs | 1341 +----------------- codex-rs/tui/src/chatwidget/constructor.rs | 266 ++++ codex-rs/tui/src/chatwidget/interaction.rs | 455 ++++++ codex-rs/tui/src/chatwidget/notifications.rs | 128 ++ codex-rs/tui/src/chatwidget/rendering.rs | 129 ++ codex-rs/tui/src/chatwidget/review_popups.rs | 182 +++ codex-rs/tui/src/chatwidget/session_flow.rs | 207 +++ 7 files changed, 1376 insertions(+), 1332 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/constructor.rs create mode 100644 codex-rs/tui/src/chatwidget/interaction.rs create mode 100644 codex-rs/tui/src/chatwidget/notifications.rs create mode 100644 codex-rs/tui/src/chatwidget/rendering.rs create mode 100644 codex-rs/tui/src/chatwidget/review_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/session_flow.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f8bb6cd77..57adb065b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -317,6 +317,7 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod command_lifecycle; mod connectors; +mod constructor; use self::connectors::ConnectorsState; mod exec_state; use self::exec_state::RunningCommand; @@ -345,10 +346,12 @@ mod keymap_picker; mod mcp_startup; use self::mcp_startup::McpStartupStatus; mod pets; +mod session_flow; mod session_header; use self::session_header::SessionHeader; mod hook_lifecycle; mod hooks; +mod interaction; mod skills; mod slash_dispatch; use self::skills::collect_tool_mentions; @@ -361,6 +364,8 @@ use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; mod model_popups; +mod notifications; +use self::notifications::Notification; mod permission_popups; mod protocol; mod protocol_requests; @@ -372,11 +377,15 @@ use self::rate_limits::app_server_rate_limit_error_kind; pub(crate) use self::rate_limits::get_limits_duration; use self::rate_limits::is_app_server_cyber_policy_error; mod realtime; +mod rendering; mod replay; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; mod review; +mod review_popups; use self::review::ReviewState; +#[cfg(test)] +pub(crate) use self::review_popups::show_review_commit_picker_with_entries; mod service_tiers; mod settings; mod settings_popups; @@ -915,205 +924,6 @@ impl ChatWidget { self.transcript.record_visible_user_turn(); } - // --- Small event handlers --- - fn on_session_configured_with_display_and_fork_parent_title( - &mut self, - session: ThreadSessionState, - display: SessionConfiguredDisplay, - fork_parent_title: Option, - ) { - self.transcript.reset_copy_history(); - let history_metadata = session.message_history.unwrap_or_default(); - self.bottom_pane.set_history_metadata( - session.thread_id, - history_metadata.log_id, - history_metadata.entry_count, - ); - self.set_skills(/*skills*/ None); - self.session_network_proxy = session.network_proxy.clone(); - let previous_thread_id = self.thread_id; - self.thread_id = Some(session.thread_id); - if previous_thread_id != self.thread_id { - self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); - } - self.refresh_plan_mode_nudge(); - self.turn_lifecycle.reset_thread(); - self.thread_name = session.thread_name.clone(); - self.current_goal_status_indicator = None; - self.current_goal_status = None; - self.update_collaboration_mode_indicator(); - self.forked_from = session.forked_from_id; - self.current_rollout_path = session.rollout_path.clone(); - self.current_cwd = Some(session.cwd.to_path_buf()); - self.config.cwd = session.cwd.clone(); - self.effective_service_tier = session.service_tier.clone(); - if let Err(err) = self - .config - .permissions - .approval_policy - .set(session.approval_policy.to_core()) - { - tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured"); - self.config.permissions.approval_policy = - Constrained::allow_only(session.approval_policy.to_core()); - } - let permission_sync = self - .config - .permissions - .set_permission_profile_with_active_profile( - session.permission_profile.clone(), - session.active_permission_profile.clone(), - ); - if let Err(err) = permission_sync { - tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); - self.config.permissions.permission_profile = - Constrained::allow_only(session.permission_profile.clone()); - self.config.permissions.active_permission_profile = - session.active_permission_profile.clone(); - } - self.config.approvals_reviewer = session.approvals_reviewer; - self.status_line_project_root_name_cache = None; - let forked_from_id = session.forked_from_id; - let model_for_header = session.model.clone(); - self.session_header.set_model(&model_for_header); - self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - Some(model_for_header.clone()), - Some(session.reasoning_effort), - /*developer_instructions*/ None, - ); - if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.model = Some(model_for_header.clone()); - mask.reasoning_effort = Some(session.reasoning_effort); - } - self.refresh_model_display(); - self.refresh_status_surfaces(); - self.sync_service_tier_commands(); - self.sync_personality_command_enabled(); - self.sync_plugins_command_enabled(); - self.sync_goal_command_enabled(); - self.refresh_plugin_mentions(); - if display == SessionConfiguredDisplay::Normal { - let startup_tooltip_override = self.startup_tooltip_override.take(); - let show_fast_status = self - .should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); - let session_info_cell = history_cell::new_session_info( - &self.config, - &model_for_header, - &session, - self.show_welcome_banner, - startup_tooltip_override, - self.plan_type, - show_fast_status, - ); - self.apply_session_info_cell(session_info_cell); - } else if self - .transcript - .active_cell - .as_ref() - .is_some_and(|cell| cell.as_any().is::()) - { - self.transcript.active_cell = None; - self.bump_active_cell_revision(); - } - self.transcript.saw_copy_source_this_turn = false; - self.refresh_skills_for_current_cwd(/*force_reload*/ true); - if self.connectors_enabled() { - self.prefetch_connectors(); - } - if let Some(user_message) = self.initial_user_message.take() { - if self.suppress_initial_user_message_submit { - self.initial_user_message = Some(user_message); - } else { - self.submit_user_message(user_message); - } - } - if display == SessionConfiguredDisplay::Normal - && let Some(forked_from_id) = forked_from_id - { - self.emit_forked_thread_event(forked_from_id, fork_parent_title); - } - if !self.suppress_session_configured_redraw { - self.request_redraw(); - } - } - - pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) { - self.instruction_source_paths = session.instruction_source_paths.clone(); - let fork_parent_title = session.fork_parent_title.clone(); - self.on_session_configured_with_display_and_fork_parent_title( - session, - SessionConfiguredDisplay::Normal, - fork_parent_title, - ); - } - - pub(crate) fn handle_thread_session_quiet(&mut self, session: ThreadSessionState) { - self.instruction_source_paths = session.instruction_source_paths.clone(); - self.on_session_configured_with_display_and_fork_parent_title( - session, - SessionConfiguredDisplay::Quiet, - /*fork_parent_title*/ None, - ); - } - - pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) { - self.instruction_source_paths = session.instruction_source_paths.clone(); - let fork_parent_title = session.fork_parent_title.clone(); - self.on_session_configured_with_display_and_fork_parent_title( - session, - SessionConfiguredDisplay::SideConversation, - fork_parent_title, - ); - } - - fn emit_forked_thread_event( - &mut self, - forked_from_id: ThreadId, - fork_parent_title: Option, - ) { - let forked_from_id_text = forked_from_id.to_string(); - let line: Line<'static> = if let Some(name) = fork_parent_title - && !name.trim().is_empty() - { - vec![ - "• ".dim(), - "Thread forked from ".into(), - name.cyan(), - " (".into(), - forked_from_id_text.cyan(), - ")".into(), - ] - .into() - } else { - vec![ - "• ".dim(), - "Thread forked from ".into(), - forked_from_id_text.cyan(), - ] - .into() - }; - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - PlainHistoryCell::new(vec![line]), - ))); - } - - fn on_thread_name_updated(&mut self, thread_id: ThreadId, thread_name: Option) { - if self.thread_id == Some(thread_id) { - if let Some(name) = thread_name.as_deref() { - let cell = Self::rename_confirmation_cell(name, self.thread_id); - self.add_boxed_history(Box::new(cell)); - } - self.thread_name = thread_name; - self.refresh_status_surfaces(); - self.request_redraw(); - self.maybe_send_next_queued_input(); - } - } - - fn set_skills(&mut self, skills: Option>) { - self.bottom_pane.set_skills(skills); - } - pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1337,597 +1147,6 @@ impl ChatWidget { } } - pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { - Self::new_with_op_target(common, CodexOpTarget::AppEvent) - } - - fn new_with_op_target(common: ChatWidgetInit, codex_op_target: CodexOpTarget) -> Self { - let ChatWidgetInit { - config, - environment_manager, - frame_requester, - app_event_tx, - workspace_command_runner, - initial_user_message, - enhanced_keys_supported, - has_chatgpt_account, - model_catalog, - feedback, - is_first_run, - status_account_display, - runtime_model_provider_base_url, - initial_plan_type, - model, - startup_tooltip_override, - status_line_invalid_items_warned, - terminal_title_invalid_items_warned, - session_telemetry, - } = common; - let model = model.filter(|m| !m.trim().is_empty()); - let mut config = config; - config.model = model.clone(); - let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); - let mut rng = rand::rng(); - let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let side_placeholder = - SIDE_PLACEHOLDERS[rng.random_range(0..SIDE_PLACEHOLDERS.len())].to_string(); - - let model_override = model.as_deref(); - let model_for_header = model - .clone() - .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); - let active_collaboration_mask = - Self::initial_collaboration_mask(&config, model_catalog.as_ref(), model_override); - let header_model = active_collaboration_mask - .as_ref() - .and_then(|mask| mask.model.clone()) - .unwrap_or_else(|| model_for_header.clone()); - let fallback_default = Settings { - model: header_model.clone(), - reasoning_effort: None, - developer_instructions: None, - }; - // Collaboration modes start in Default mode. - let current_collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings: fallback_default, - }; - - let active_cell = Some(Self::placeholder_session_header_cell(&config)); - - let current_cwd = Some(config.cwd.to_path_buf()); - let effective_service_tier = config.service_tier.clone(); - let current_terminal_info = terminal_info(); - let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); - let default_keymap = RuntimeKeymap::defaults(); - let copy_last_response_binding = runtime_keymap - .as_ref() - .map(|keymap| keymap.app.copy.clone()) - .unwrap_or_else(|| default_keymap.app.copy.clone()); - let chat_keymap = runtime_keymap - .as_ref() - .map(|keymap| keymap.chat.clone()) - .unwrap_or_else(|| default_keymap.chat.clone()); - let queued_message_edit_hint_binding = queued_message_edit_hint_binding( - &chat_keymap.edit_queued_message, - current_terminal_info, - ); - pets::start_configured_pet_load_if_needed( - &config, - /*ambient_pet_missing*/ true, - frame_requester.clone(), - app_event_tx.clone(), - ); - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_target, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder.clone(), - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - transcript: TranscriptState::new(active_cell), - raw_output_mode: config.tui_raw_output_mode, - config, - environment_manager, - effective_service_tier, - skills_all: Vec::new(), - skills_initial_state: None, - current_collaboration_mode, - active_collaboration_mask, - has_chatgpt_account, - model_catalog, - session_telemetry, - session_header: SessionHeader::new(header_model), - initial_user_message, - status_account_display, - runtime_model_provider_base_url, - token_info: None, - rate_limit_snapshots_by_limit_id: BTreeMap::new(), - refreshing_status_outputs: Vec::new(), - next_status_refresh_request_id: 0, - plan_type: initial_plan_type, - codex_rate_limit_reached_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - warning_display_state: WarningDisplayState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - add_credits_nudge_email_in_flight: None, - adaptive_chunking: AdaptiveChunkingPolicy::default(), - stream_controller: None, - plan_stream_controller: None, - clipboard_lease: None, - copy_last_response_binding, - running_commands: HashMap::new(), - collab_agent_metadata: HashMap::new(), - pending_collab_spawn_requests: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - unified_exec_wait_streak: None, - turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep), - task_complete_pending: false, - unified_exec_processes: Vec::new(), - mcp_startup_status: None, - mcp_startup_expected_servers: None, - mcp_startup_ignore_updates_until_next_start: false, - mcp_startup_allow_terminal_only_next_round: false, - mcp_startup_pending_next_round: HashMap::new(), - mcp_startup_pending_next_round_saw_starting: false, - connectors: ConnectorsState::default(), - ide_context: IdeContextState::default(), - plugins_cache: PluginsCacheState::default(), - plugins_fetch_state: PluginListFetchState::default(), - plugin_install_apps_needing_auth: Vec::new(), - plugin_install_auth_flow: None, - plugins_active_tab_id: None, - newly_installed_marketplace_tab_id: None, - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - status_state: StatusState::default(), - review: ReviewState::default(), - active_hook_cell: None, - ambient_pet: None, - pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(), - pet_picker_preview_pet: None, - pet_picker_preview_request_id: 0, - pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false), - pet_selection_load_request_id: 0, - #[cfg(test)] - pet_image_support_override: None, - thread_id: None, - dismissed_plan_mode_nudge_scopes: HashSet::new(), - thread_name: None, - thread_rename_block_message: None, - active_side_conversation: false, - normal_placeholder_text: placeholder, - side_placeholder_text: side_placeholder, - forked_from: None, - interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, - input_queue: InputQueueState::default(), - chat_keymap, - queued_message_edit_hint_binding, - show_welcome_banner: is_first_run, - startup_tooltip_override, - suppress_session_configured_redraw: false, - suppress_initial_user_message_submit: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - turn_runtime_metrics: RuntimeMetricsSummary::default(), - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - current_cwd, - workspace_command_runner, - instruction_source_paths: Vec::new(), - session_network_proxy: None, - status_line_invalid_items_warned, - terminal_title_invalid_items_warned, - last_terminal_title: None, - last_terminal_title_requires_action: false, - terminal_title_setup_original_items: None, - terminal_title_animation_origin: Instant::now(), - status_line_project_root_name_cache: None, - status_line_branch: None, - status_line_branch_cwd: None, - status_line_branch_pending: false, - status_line_branch_lookup_complete: false, - status_line_git_summary: None, - status_line_git_summary_cwd: None, - status_line_git_summary_pending: false, - status_line_git_summary_lookup_complete: false, - current_goal_status_indicator: None, - current_goal_status: None, - external_editor_state: ExternalEditorState::Closed, - realtime_conversation: RealtimeConversationUiState::default(), - last_rendered_user_message_display: None, - last_non_retry_error: None, - }; - - widget.prefetch_rate_limits(); - if let Some(keymap) = runtime_keymap { - widget.bottom_pane.set_keymap_bindings(&keymap); - } - widget - .bottom_pane - .set_vim_enabled(widget.config.tui_vim_mode_default); - widget - .bottom_pane - .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); - widget - .bottom_pane - .set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled()); - widget - .bottom_pane - .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); - widget - .bottom_pane - .set_collaboration_modes_enabled(/*enabled*/ true); - widget.sync_service_tier_commands(); - widget.sync_personality_command_enabled(); - widget.sync_plugins_command_enabled(); - widget.sync_goal_command_enabled(); - widget.sync_mentions_v2_enabled(); - widget - .bottom_pane - .set_queued_message_edit_binding(widget.queued_message_edit_hint_binding); - #[cfg(target_os = "windows")] - widget.bottom_pane.set_windows_degraded_sandbox_active( - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && matches!( - WindowsSandboxLevel::from_config(&widget.config), - WindowsSandboxLevel::RestrictedToken - ), - ); - widget.update_collaboration_mode_indicator(); - - widget - .bottom_pane - .set_connectors_enabled(widget.connectors_enabled()); - widget.refresh_status_surfaces(); - - widget - } - - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { - if self.bottom_pane.has_active_view() - && !matches!( - key_event, - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') - ) - && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) - && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) - { - self.bottom_pane.handle_key_event(key_event); - if self.bottom_pane.no_modal_or_popup_active() { - self.maybe_send_next_queued_input(); - } - return; - } - - if self.handle_reasoning_shortcut(key_event) { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - return; - } - - if key_event.kind == KeyEventKind::Press - && self.copy_last_response_binding.is_pressed(key_event) - { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.copy_last_agent_markdown(); - return; - } - - match key_event { - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { - self.on_ctrl_c(); - return; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => { - if self.on_ctrl_d() { - return; - } - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && c.eq_ignore_ascii_case(&'v') => - { - match paste_image_to_temp_png() { - Ok((path, info)) => { - tracing::debug!( - "pasted image size={}x{} format={}", - info.width, - info.height, - info.encoded_format.label() - ); - self.attach_image(path); - } - Err(err) => { - tracing::warn!("failed to paste image: {err}"); - self.add_to_history(history_cell::new_error_event(format!( - "Failed to paste image: {err}", - ))); - } - } - return; - } - other if other.kind == KeyEventKind::Press => { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - } - _ => {} - } - - if key_event.kind == KeyEventKind::Press - && self.chat_keymap.edit_queued_message.is_pressed(key_event) - && self.has_queued_follow_up_messages() - && self.bottom_pane.no_modal_or_popup_active() - { - if let Some(user_message) = self.pop_latest_queued_user_message() { - self.restore_user_message_to_composer(user_message); - self.refresh_pending_input_preview(); - self.request_redraw(); - } - return; - } - - if matches!(key_event.code, KeyCode::Esc) - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && !self.input_queue.pending_steers.is_empty() - && self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() - && !self.should_handle_vim_insert_escape(key_event) - { - self.input_queue.submit_pending_steers_after_interrupt = true; - if !self.submit_op(AppCommand::interrupt()) { - self.input_queue.submit_pending_steers_after_interrupt = false; - } - return; - } - - if matches!(key_event.code, KeyCode::Esc) - && key_event.kind == KeyEventKind::Press - && self.should_show_plan_mode_nudge() - { - self.dismiss_plan_mode_nudge(); - return; - } - - if self.handle_plugins_popup_key_event(key_event) { - return; - } - - match key_event { - KeyEvent { - code: KeyCode::BackTab, - kind: KeyEventKind::Press, - .. - } if self.collaboration_modes_enabled() - && !self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() => - { - self.cycle_collaboration_mode(); - self.refresh_plan_mode_nudge(); - } - _ => { - let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); - let input_result = self.bottom_pane.handle_key_event(key_event); - self.handle_composer_input_result(input_result, had_modal_or_popup); - } - } - } - - /// Attach a local image to the composer when the active model supports image inputs. - /// - /// When the model does not advertise image support, we keep the draft unchanged and surface a - /// warning event so users can switch models or remove attachments. - pub(crate) fn attach_image(&mut self, path: PathBuf) { - if !self.current_model_supports_images() { - self.add_to_history(history_cell::new_warning_event( - self.image_inputs_not_supported_message(), - )); - self.request_redraw(); - return; - } - tracing::info!("attach_image path={path:?}"); - self.bottom_pane.attach_image(path); - self.request_redraw(); - } - - pub(crate) fn composer_text_with_pending(&self) -> String { - self.bottom_pane.composer_text_with_pending() - } - - pub(crate) fn apply_external_edit(&mut self, text: String) { - self.bottom_pane.apply_external_edit(text); - self.refresh_plan_mode_nudge(); - self.request_redraw(); - } - - pub(crate) fn external_editor_state(&self) -> ExternalEditorState { - self.external_editor_state - } - - pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) { - self.external_editor_state = state; - } - - pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { - self.bottom_pane.set_footer_hint_override(items); - } - - pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { - self.bottom_pane.show_selection_view(params); - self.refresh_plan_mode_nudge(); - self.request_redraw(); - } - - pub(crate) fn no_modal_or_popup_active(&self) -> bool { - self.bottom_pane.no_modal_or_popup_active() - } - - pub(crate) fn can_launch_external_editor(&self) -> bool { - self.bottom_pane.can_launch_external_editor() - } - - pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool { - // Ctrl+L is not a slash command, but it follows /clear's current rule: - // block while a task is running. - if !self.bottom_pane.is_task_running() { - return true; - } - - let message = "Ctrl+L is disabled while a task is in progress.".to_string(); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - false - } - - /// Copy the last agent response (raw markdown) to the system clipboard. - pub(crate) fn copy_last_agent_markdown(&mut self) { - self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); - } - - pub(crate) fn truncate_agent_copy_history_to_user_turn_count( - &mut self, - user_turn_count: usize, - ) { - self.transcript - .truncate_copy_history_to_user_turn_count(user_turn_count); - } - - /// Inner implementation with an injectable clipboard backend for testing. - fn copy_last_agent_markdown_with( - &mut self, - copy_fn: impl FnOnce(&str) -> Result, String>, - ) { - match self.transcript.last_agent_markdown.clone() { - Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { - Ok(lease) => { - self.clipboard_lease = lease; - self.add_to_history(history_cell::new_info_event( - "Copied last message to clipboard".into(), - /*hint*/ None, - )); - } - Err(error) => self.add_to_history(history_cell::new_error_event(format!( - "Copy failed: {error}" - ))), - }, - _ if self.transcript.copy_history_evicted_by_rollback => { - self.add_to_history(history_cell::new_error_event(format!( - "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." - ))); - } - _ => self.add_to_history(history_cell::new_error_event( - "No agent response to copy".into(), - )), - } - self.request_redraw(); - } - - #[cfg(test)] - pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { - self.transcript.last_agent_markdown.as_deref() - } - - fn show_rename_prompt(&mut self) { - if !self.ensure_thread_rename_allowed() { - return; - } - let tx = self.app_event_tx.clone(); - let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); - let title = if existing_name.is_some() { - "Rename thread" - } else { - "Name thread" - }; - let view = CustomPromptView::new( - title.to_string(), - "Type a name and press Enter".to_string(), - /*initial_text*/ existing_name.unwrap_or_default().to_string(), - /*context_label*/ None, - Box::new(move |name: String| { - let Some(name) = crate::legacy_core::util::normalize_thread_name(&name) else { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event("Thread name cannot be empty.".to_string()), - ))); - return; - }; - tx.set_thread_name(name); - }), - ); - - self.bottom_pane.show_view(Box::new(view)); - } - - fn ensure_thread_rename_allowed(&mut self) -> bool { - match self.thread_rename_block_message.clone() { - Some(message) => { - self.add_error_message(message); - false - } - None => true, - } - } - - pub(crate) fn handle_paste(&mut self, text: String) { - self.bottom_pane.handle_paste(text); - self.refresh_plan_mode_nudge(); - } - - // Returns true if caller should skip rendering this frame (a future frame is scheduled). - pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { - if self.bottom_pane.flush_paste_burst_if_due() { - self.refresh_plan_mode_nudge(); - // A paste just flushed; request an immediate redraw and skip this frame. - self.request_redraw(); - true - } else if self.bottom_pane.is_in_paste_burst() { - // While capturing a burst, schedule a follow-up tick and skip this frame - // to avoid redundant renders between ticks. - frame_requester.schedule_frame_in( - crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), - ); - true - } else { - false - } - } - fn flush_active_cell(&mut self) { if let Some(active) = self.transcript.active_cell.take() { self.transcript.needs_final_message_separator = true; @@ -2063,25 +1282,6 @@ impl ChatWidget { self.transcript.bump_active_cell_revision(); } - fn notify(&mut self, notification: Notification) { - if !notification.allowed_for(&self.config.tui_notifications.notifications) { - return; - } - if let Some(existing) = self.pending_notification.as_ref() - && existing.priority() > notification.priority() - { - return; - } - self.pending_notification = Some(notification); - self.request_redraw(); - } - - pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { - if let Some(notif) = self.pending_notification.take() { - tui.notify(notif.display()); - } - } - /// Mark the active cell as failed (✗) and flush it into history. fn finalize_active_cell_as_failed(&mut self) { if let Some(mut cell) = self.transcript.active_cell.take() { @@ -2287,123 +1487,6 @@ impl ChatWidget { self.bottom_pane.on_file_search_result(query, matches); } - /// Handles a Ctrl+C press at the chat-widget layer. - /// - /// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom - /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut - /// is armed. - /// - /// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the - /// first press always stops live voice, even when the composer contains the recording meter. - /// - /// When the double-press quit shortcut is enabled, pressing the same shortcut again before - /// expiry requests a shutdown-first quit. - fn on_ctrl_c(&mut self) { - let key = key_hint::ctrl(KeyCode::Char('c')); - if self.realtime_conversation.is_live() { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.stop_realtime_conversation_from_ui(); - return; - } - let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); - if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { - if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if modal_or_popup_active { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.bottom_pane.clear_quit_shortcut_hint(); - } else { - self.arm_quit_shortcut(key); - } - } - return; - } - - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if self.is_cancellable_work_active() { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.bottom_pane.clear_quit_shortcut_hint(); - self.submit_op(AppCommand::interrupt()); - } else { - self.request_quit_without_confirmation(); - } - return; - } - - if self.quit_shortcut_active_for(key) { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.request_quit_without_confirmation(); - return; - } - - self.arm_quit_shortcut(key); - - if self.is_cancellable_work_active() { - self.submit_op(AppCommand::interrupt()); - } - } - - /// Handles a Ctrl+D press at the chat-widget layer. - /// - /// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active. - /// Otherwise it should be routed to the active view and not attempt to quit. - fn on_ctrl_d(&mut self) -> bool { - let key = key_hint::ctrl(KeyCode::Char('d')); - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() - { - return false; - } - - self.request_quit_without_confirmation(); - return true; - } - - if self.quit_shortcut_active_for(key) { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.request_quit_without_confirmation(); - return true; - } - - if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() { - return false; - } - - self.arm_quit_shortcut(key); - true - } - - /// True if `key` matches the armed quit shortcut and the window has not expired. - fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool { - self.quit_shortcut_key == Some(key) - && self - .quit_shortcut_expires_at - .is_some_and(|expires_at| Instant::now() < expires_at) - } - - /// Arm the double-press quit shortcut and show the footer hint. - /// - /// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since - /// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether - /// quitting is currently allowed, while delegating rendering to `BottomPane`. - fn arm_quit_shortcut(&mut self, key: KeyBinding) { - self.quit_shortcut_expires_at = Instant::now() - .checked_add(QUIT_SHORTCUT_TIMEOUT) - .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = Some(key); - self.bottom_pane.show_quit_shortcut_hint(key); - } - - // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. - fn is_cancellable_work_active(&self) -> bool { - self.bottom_pane.is_task_running() || self.review.is_review_mode - } - /// Return the markdown body width available to an active stream. /// /// Streaming controllers render only the message body, while history cells add bullets, @@ -2669,149 +1752,6 @@ impl ChatWidget { self.sync_mentions_v2_enabled(); } - pub(crate) fn open_review_popup(&mut self) { - let mut items: Vec = Vec::new(); - - items.push(SelectionItem { - name: "Review against a base branch".to_string(), - description: Some("(PR Style)".into()), - actions: vec![Box::new({ - let cwd = self.config.cwd.to_path_buf(); - move |tx| { - tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - dismiss_parent_on_child_accept: true, - ..Default::default() - }); - - items.push(SelectionItem { - name: "Review uncommitted changes".to_string(), - actions: vec![Box::new(move |tx: &AppEventSender| { - tx.review(ReviewTarget::UncommittedChanges); - })], - dismiss_on_select: true, - ..Default::default() - }); - - // New: Review a specific commit (opens commit picker) - items.push(SelectionItem { - name: "Review a commit".to_string(), - actions: vec![Box::new({ - let cwd = self.config.cwd.to_path_buf(); - move |tx| { - tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - dismiss_parent_on_child_accept: true, - ..Default::default() - }); - - items.push(SelectionItem { - name: "Custom review instructions".to_string(), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenReviewCustomPrompt); - })], - dismiss_on_select: false, - dismiss_parent_on_child_accept: true, - ..Default::default() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a review preset".into()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { - let branches = local_git_branches(cwd).await; - let current_branch = current_branch_name(cwd) - .await - .unwrap_or_else(|| "(detached HEAD)".to_string()); - let mut items: Vec = Vec::with_capacity(branches.len()); - - for option in branches { - let branch = option.clone(); - items.push(SelectionItem { - name: format!("{current_branch} -> {branch}"), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewTarget::BaseBranch { - branch: branch.clone(), - }); - })], - dismiss_on_select: true, - search_value: Some(option), - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a base branch".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search branches".to_string()), - ..Default::default() - }); - } - - pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = recent_commits(cwd, /*limit*/ 100).await; - - let mut items: Vec = Vec::with_capacity(commits.len()); - for entry in commits { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); - } - - pub(crate) fn show_review_custom_prompt(&mut self) { - let tx = self.app_event_tx.clone(); - let view = CustomPromptView::new( - "Custom review instructions".to_string(), - "Type instructions and press Enter".to_string(), - /*initial_text*/ String::new(), - /*context_label*/ None, - Box::new(move |prompt: String| { - let trimmed = prompt.trim().to_string(); - if trimmed.is_empty() { - return; - } - tx.review(ReviewTarget::Custom { - instructions: trimmed, - }); - }), - ); - self.bottom_pane.show_view(Box::new(view)); - } - pub(crate) fn token_usage(&self) -> TokenUsage { self.token_info .as_ref() @@ -2901,111 +1841,6 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; } - - fn as_renderable(&self) -> RenderableItem<'_> { - let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); - let active_cell_renderable = match &self.transcript.active_cell { - Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable { - child: cell.as_ref(), - top: 1, - right: active_cell_right_reserve, - })), - None => RenderableItem::Owned(Box::new(())), - }; - let active_hook_cell_renderable = match &self.active_hook_cell { - Some(cell) if cell.should_render() => { - RenderableItem::Owned(Box::new(TranscriptAreaRenderable { - child: cell, - top: 1, - right: active_cell_right_reserve, - })) - } - _ => RenderableItem::Owned(Box::new(())), - }; - let mut flex = FlexRenderable::new(); - flex.push(/*flex*/ 1, active_cell_renderable); - flex.push(/*flex*/ 0, active_hook_cell_renderable); - flex.push( - /*flex*/ 0, - RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable { - bottom_pane: &self.bottom_pane, - right_reserve: active_cell_right_reserve, - })) - .inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )), - ); - RenderableItem::Owned(Box::new(flex)) - } -} - -struct BottomPaneComposerReserveRenderable<'a> { - bottom_pane: &'a BottomPane, - right_reserve: u16, -} - -impl Renderable for BottomPaneComposerReserveRenderable<'_> { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.bottom_pane - .render_with_composer_right_reserve(area, buf, self.right_reserve); - } - - fn desired_height(&self, width: u16) -> u16 { - self.bottom_pane - .desired_height_with_composer_right_reserve(width, self.right_reserve) - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.bottom_pane - .cursor_pos_with_composer_right_reserve(area, self.right_reserve) - } - - fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { - self.bottom_pane - .cursor_style_with_composer_right_reserve(area, self.right_reserve) - } -} - -struct TranscriptAreaRenderable<'a> { - child: &'a dyn HistoryCell, - top: u16, - right: u16, -} - -impl Renderable for TranscriptAreaRenderable<'_> { - fn render(&self, area: Rect, buf: &mut Buffer) { - let area = self.child_area(area); - let lines = self.child.display_lines(area.width); - let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - let y = if area.height == 0 { - 0 - } else { - let overflow = paragraph - .line_count(area.width) - .saturating_sub(usize::from(area.height)); - u16::try_from(overflow).unwrap_or(u16::MAX) - }; - Clear.render(area, buf); - paragraph.scroll((y, 0)).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - let child_width = width.saturating_sub(self.right).max(1); - HistoryCell::desired_height(self.child, child_width) + self.top - } -} - -impl TranscriptAreaRenderable<'_> { - fn child_area(&self, area: Rect) -> Rect { - let y = area.y.saturating_add(self.top); - let height = area.height.saturating_sub(self.top); - Rect::new( - area.x, - y, - area.width.saturating_sub(self.right).max(1), - height, - ) - } } #[cfg(not(target_os = "linux"))] @@ -3041,129 +1876,6 @@ impl Drop for ChatWidget { } } -impl Renderable for ChatWidget { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.as_renderable().render(area, buf); - self.last_rendered_width.set(Some(area.width as usize)); - } - - 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) - } -} - -#[derive(Debug)] -enum Notification { - AgentTurnComplete { response: String }, - ExecApprovalRequested { command: String }, - EditApprovalRequested { cwd: PathBuf, changes: Vec }, - ElicitationRequested { server_name: String }, - PlanModePrompt { title: String }, -} - -impl Notification { - fn display(&self) -> String { - match self { - Notification::AgentTurnComplete { response } => { - Notification::agent_turn_preview(response) - .unwrap_or_else(|| "Agent turn complete".to_string()) - } - Notification::ExecApprovalRequested { command } => { - format!( - "Approval requested: {}", - truncate_text(command, /*max_graphemes*/ 30) - ) - } - Notification::EditApprovalRequested { cwd, changes } => { - format!( - "Codex wants to edit {}", - if changes.len() == 1 { - #[allow(clippy::unwrap_used)] - display_path_for(changes.first().unwrap(), cwd) - } else { - format!("{} files", changes.len()) - } - ) - } - Notification::ElicitationRequested { server_name } => { - format!("Approval requested by {server_name}") - } - Notification::PlanModePrompt { title } => { - format!("Plan mode prompt: {title}") - } - } - } - - fn type_name(&self) -> &str { - match self { - Notification::AgentTurnComplete { .. } => "agent-turn-complete", - Notification::ExecApprovalRequested { .. } - | Notification::EditApprovalRequested { .. } - | Notification::ElicitationRequested { .. } => "approval-requested", - Notification::PlanModePrompt { .. } => "plan-mode-prompt", - } - } - - fn priority(&self) -> u8 { - match self { - Notification::AgentTurnComplete { .. } => 0, - Notification::ExecApprovalRequested { .. } - | Notification::EditApprovalRequested { .. } - | Notification::ElicitationRequested { .. } - | Notification::PlanModePrompt { .. } => 1, - } - } - - fn allowed_for(&self, settings: &Notifications) -> bool { - match settings { - Notifications::Enabled(enabled) => *enabled, - Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), - } - } - - fn agent_turn_preview(response: &str) -> Option { - let mut normalized = String::new(); - for part in response.split_whitespace() { - if !normalized.is_empty() { - normalized.push(' '); - } - normalized.push_str(part); - } - let trimmed = normalized.trim(); - if trimmed.is_empty() { - None - } else { - Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) - } - } - - fn user_input_request_summary( - questions: &[codex_app_server_protocol::ToolRequestUserInputQuestion], - ) -> Option { - let first_question = questions.first()?; - let summary = if first_question.header.trim().is_empty() { - first_question.question.trim() - } else { - first_question.header.trim() - }; - if summary.is_empty() { - None - } else { - Some(truncate_text(summary, /*max_graphemes*/ 30)) - } - } -} - -const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; - const PLACEHOLDERS: [&str; 8] = [ "Explain this codebase", "Summarize recent commits", @@ -3211,40 +1923,5 @@ fn extract_first_bold(s: &str) -> Option { None } -#[cfg(test)] -pub(crate) fn show_review_commit_picker_with_entries( - chat: &mut ChatWidget, - entries: Vec, -) { - let mut items: Vec = Vec::with_capacity(entries.len()); - for entry in entries { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.review(ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - chat.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); -} - #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs new file mode 100644 index 000000000..db55be3a8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -0,0 +1,266 @@ +//! Construction and initial wiring for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self { + Self::new_with_op_target(common, CodexOpTarget::AppEvent) + } + + pub(super) fn new_with_op_target( + common: ChatWidgetInit, + codex_op_target: CodexOpTarget, + ) -> Self { + let ChatWidgetInit { + config, + environment_manager, + frame_requester, + app_event_tx, + workspace_command_runner, + initial_user_message, + enhanced_keys_supported, + has_chatgpt_account, + model_catalog, + feedback, + is_first_run, + status_account_display, + runtime_model_provider_base_url, + initial_plan_type, + model, + startup_tooltip_override, + status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + session_telemetry, + } = common; + let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; + config.model = model.clone(); + let prevent_idle_sleep = config.features.enabled(Feature::PreventIdleSleep); + let mut rng = rand::rng(); + let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); + let side_placeholder = + SIDE_PLACEHOLDERS[rng.random_range(0..SIDE_PLACEHOLDERS.len())].to_string(); + + let model_override = model.as_deref(); + let model_for_header = model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, model_catalog.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or_else(|| model_for_header.clone()); + let fallback_default = Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }; + // Collaboration modes start in Default mode. + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: fallback_default, + }; + + let active_cell = Some(Self::placeholder_session_header_cell(&config)); + + let current_cwd = Some(config.cwd.to_path_buf()); + let effective_service_tier = config.service_tier.clone(); + let current_terminal_info = terminal_info(); + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); + let default_keymap = RuntimeKeymap::defaults(); + let copy_last_response_binding = runtime_keymap + .as_ref() + .map(|keymap| keymap.app.copy.clone()) + .unwrap_or_else(|| default_keymap.app.copy.clone()); + let chat_keymap = runtime_keymap + .as_ref() + .map(|keymap| keymap.chat.clone()) + .unwrap_or_else(|| default_keymap.chat.clone()); + let queued_message_edit_hint_binding = queued_message_edit_hint_binding( + &chat_keymap.edit_queued_message, + current_terminal_info, + ); + pets::start_configured_pet_load_if_needed( + &config, + /*ambient_pet_missing*/ true, + frame_requester.clone(), + app_event_tx.clone(), + ); + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_target, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder.clone(), + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills: None, + }), + transcript: TranscriptState::new(active_cell), + raw_output_mode: config.tui_raw_output_mode, + config, + environment_manager, + effective_service_tier, + skills_all: Vec::new(), + skills_initial_state: None, + current_collaboration_mode, + active_collaboration_mask, + has_chatgpt_account, + model_catalog, + session_telemetry, + session_header: SessionHeader::new(header_model), + initial_user_message, + status_account_display, + runtime_model_provider_base_url, + token_info: None, + rate_limit_snapshots_by_limit_id: BTreeMap::new(), + refreshing_status_outputs: Vec::new(), + next_status_refresh_request_id: 0, + plan_type: initial_plan_type, + codex_rate_limit_reached_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + warning_display_state: WarningDisplayState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + add_credits_nudge_email_in_flight: None, + adaptive_chunking: AdaptiveChunkingPolicy::default(), + stream_controller: None, + plan_stream_controller: None, + clipboard_lease: None, + copy_last_response_binding, + running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), + pending_collab_spawn_requests: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + unified_exec_wait_streak: None, + turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep), + task_complete_pending: false, + unified_exec_processes: Vec::new(), + mcp_startup_status: None, + mcp_startup_expected_servers: None, + mcp_startup_ignore_updates_until_next_start: false, + mcp_startup_allow_terminal_only_next_round: false, + mcp_startup_pending_next_round: HashMap::new(), + mcp_startup_pending_next_round_saw_starting: false, + connectors: ConnectorsState::default(), + ide_context: IdeContextState::default(), + plugins_cache: PluginsCacheState::default(), + plugins_fetch_state: PluginListFetchState::default(), + plugin_install_apps_needing_auth: Vec::new(), + plugin_install_auth_flow: None, + plugins_active_tab_id: None, + newly_installed_marketplace_tab_id: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + status_state: StatusState::default(), + review: ReviewState::default(), + active_hook_cell: None, + ambient_pet: None, + pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(), + pet_picker_preview_pet: None, + pet_picker_preview_request_id: 0, + pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false), + pet_selection_load_request_id: 0, + #[cfg(test)] + pet_image_support_override: None, + thread_id: None, + dismissed_plan_mode_nudge_scopes: HashSet::new(), + thread_name: None, + thread_rename_block_message: None, + active_side_conversation: false, + normal_placeholder_text: placeholder, + side_placeholder_text: side_placeholder, + forked_from: None, + interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, + input_queue: InputQueueState::default(), + chat_keymap, + queued_message_edit_hint_binding, + show_welcome_banner: is_first_run, + startup_tooltip_override, + suppress_session_configured_redraw: false, + suppress_initial_user_message_submit: false, + pending_notification: None, + quit_shortcut_expires_at: None, + quit_shortcut_key: None, + turn_runtime_metrics: RuntimeMetricsSummary::default(), + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + current_cwd, + workspace_command_runner, + instruction_source_paths: Vec::new(), + session_network_proxy: None, + status_line_invalid_items_warned, + terminal_title_invalid_items_warned, + last_terminal_title: None, + last_terminal_title_requires_action: false, + terminal_title_setup_original_items: None, + terminal_title_animation_origin: Instant::now(), + status_line_project_root_name_cache: None, + status_line_branch: None, + status_line_branch_cwd: None, + status_line_branch_pending: false, + status_line_branch_lookup_complete: false, + status_line_git_summary: None, + status_line_git_summary_cwd: None, + status_line_git_summary_pending: false, + status_line_git_summary_lookup_complete: false, + current_goal_status_indicator: None, + current_goal_status: None, + external_editor_state: ExternalEditorState::Closed, + realtime_conversation: RealtimeConversationUiState::default(), + last_rendered_user_message_display: None, + last_non_retry_error: None, + }; + + widget.prefetch_rate_limits(); + if let Some(keymap) = runtime_keymap { + widget.bottom_pane.set_keymap_bindings(&keymap); + } + widget + .bottom_pane + .set_vim_enabled(widget.config.tui_vim_mode_default); + widget + .bottom_pane + .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); + widget + .bottom_pane + .set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled()); + widget + .bottom_pane + .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); + widget + .bottom_pane + .set_collaboration_modes_enabled(/*enabled*/ true); + widget.sync_service_tier_commands(); + widget.sync_personality_command_enabled(); + widget.sync_plugins_command_enabled(); + widget.sync_goal_command_enabled(); + widget.sync_mentions_v2_enabled(); + widget + .bottom_pane + .set_queued_message_edit_binding(widget.queued_message_edit_hint_binding); + #[cfg(target_os = "windows")] + widget.bottom_pane.set_windows_degraded_sandbox_active( + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&widget.config), + WindowsSandboxLevel::RestrictedToken + ), + ); + widget.update_collaboration_mode_indicator(); + + widget + .bottom_pane + .set_connectors_enabled(widget.connectors_enabled()); + widget.refresh_status_surfaces(); + + widget + } +} diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs new file mode 100644 index 000000000..2a88711f4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -0,0 +1,455 @@ +//! Key routing and composer-adjacent UI interaction for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.bottom_pane.has_active_view() + && !matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') + ) + && !key_hint::ctrl(KeyCode::Char('r')).is_press(key_event) + && !key_hint::ctrl(KeyCode::Char('u')).is_press(key_event) + { + self.bottom_pane.handle_key_event(key_event); + if self.bottom_pane.no_modal_or_popup_active() { + self.maybe_send_next_queued_input(); + } + return; + } + + if self.handle_reasoning_shortcut(key_event) { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + return; + } + + if key_event.kind == KeyEventKind::Press + && self.copy_last_response_binding.is_pressed(key_event) + { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.copy_last_agent_markdown(); + return; + } + + match key_event { + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { + self.on_ctrl_c(); + return; + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => { + if self.on_ctrl_d() { + return; + } + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && c.eq_ignore_ascii_case(&'v') => + { + match paste_image_to_temp_png() { + Ok((path, info)) => { + tracing::debug!( + "pasted image size={}x{} format={}", + info.width, + info.height, + info.encoded_format.label() + ); + self.attach_image(path); + } + Err(err) => { + tracing::warn!("failed to paste image: {err}"); + self.add_to_history(history_cell::new_error_event(format!( + "Failed to paste image: {err}", + ))); + } + } + return; + } + other if other.kind == KeyEventKind::Press => { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + } + _ => {} + } + + if key_event.kind == KeyEventKind::Press + && self.chat_keymap.edit_queued_message.is_pressed(key_event) + && self.has_queued_follow_up_messages() + && self.bottom_pane.no_modal_or_popup_active() + { + if let Some(user_message) = self.pop_latest_queued_user_message() { + self.restore_user_message_to_composer(user_message); + self.refresh_pending_input_preview(); + self.request_redraw(); + } + return; + } + + if matches!(key_event.code, KeyCode::Esc) + && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && !self.input_queue.pending_steers.is_empty() + && self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() + && !self.should_handle_vim_insert_escape(key_event) + { + self.input_queue.submit_pending_steers_after_interrupt = true; + if !self.submit_op(AppCommand::interrupt()) { + self.input_queue.submit_pending_steers_after_interrupt = false; + } + return; + } + + if matches!(key_event.code, KeyCode::Esc) + && key_event.kind == KeyEventKind::Press + && self.should_show_plan_mode_nudge() + { + self.dismiss_plan_mode_nudge(); + return; + } + + if self.handle_plugins_popup_key_event(key_event) { + return; + } + + match key_event { + KeyEvent { + code: KeyCode::BackTab, + kind: KeyEventKind::Press, + .. + } if self.collaboration_modes_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() => + { + self.cycle_collaboration_mode(); + self.refresh_plan_mode_nudge(); + } + _ => { + let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); + let input_result = self.bottom_pane.handle_key_event(key_event); + self.handle_composer_input_result(input_result, had_modal_or_popup); + } + } + } + + /// Attach a local image to the composer when the active model supports image inputs. + /// + /// When the model does not advertise image support, we keep the draft unchanged and surface a + /// warning event so users can switch models or remove attachments. + pub(crate) fn attach_image(&mut self, path: PathBuf) { + if !self.current_model_supports_images() { + self.add_to_history(history_cell::new_warning_event( + self.image_inputs_not_supported_message(), + )); + self.request_redraw(); + return; + } + tracing::info!("attach_image path={path:?}"); + self.bottom_pane.attach_image(path); + self.request_redraw(); + } + + pub(crate) fn composer_text_with_pending(&self) -> String { + self.bottom_pane.composer_text_with_pending() + } + + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.bottom_pane.apply_external_edit(text); + self.refresh_plan_mode_nudge(); + self.request_redraw(); + } + + pub(crate) fn external_editor_state(&self) -> ExternalEditorState { + self.external_editor_state + } + + pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) { + self.external_editor_state = state; + } + + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.bottom_pane.set_footer_hint_override(items); + } + + pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { + self.bottom_pane.show_selection_view(params); + self.refresh_plan_mode_nudge(); + self.request_redraw(); + } + + pub(crate) fn no_modal_or_popup_active(&self) -> bool { + self.bottom_pane.no_modal_or_popup_active() + } + + pub(crate) fn can_launch_external_editor(&self) -> bool { + self.bottom_pane.can_launch_external_editor() + } + + pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool { + // Ctrl+L is not a slash command, but it follows /clear's current rule: + // block while a task is running. + if !self.bottom_pane.is_task_running() { + return true; + } + + let message = "Ctrl+L is disabled while a task is in progress.".to_string(); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + false + } + + /// Copy the last agent response (raw markdown) to the system clipboard. + pub(crate) fn copy_last_agent_markdown(&mut self) { + self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); + } + + pub(crate) fn truncate_agent_copy_history_to_user_turn_count( + &mut self, + user_turn_count: usize, + ) { + self.transcript + .truncate_copy_history_to_user_turn_count(user_turn_count); + } + + /// Inner implementation with an injectable clipboard backend for testing. + pub(super) fn copy_last_agent_markdown_with( + &mut self, + copy_fn: impl FnOnce(&str) -> Result, String>, + ) { + match self.transcript.last_agent_markdown.clone() { + Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { + Ok(lease) => { + self.clipboard_lease = lease; + self.add_to_history(history_cell::new_info_event( + "Copied last message to clipboard".into(), + /*hint*/ None, + )); + } + Err(error) => self.add_to_history(history_cell::new_error_event(format!( + "Copy failed: {error}" + ))), + }, + _ if self.transcript.copy_history_evicted_by_rollback => { + self.add_to_history(history_cell::new_error_event(format!( + "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." + ))); + } + _ => self.add_to_history(history_cell::new_error_event( + "No agent response to copy".into(), + )), + } + self.request_redraw(); + } + + #[cfg(test)] + pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { + self.transcript.last_agent_markdown.as_deref() + } + + pub(super) fn show_rename_prompt(&mut self) { + if !self.ensure_thread_rename_allowed() { + return; + } + let tx = self.app_event_tx.clone(); + let existing_name = self.thread_name.as_deref().filter(|name| !name.is_empty()); + let title = if existing_name.is_some() { + "Rename thread" + } else { + "Name thread" + }; + let view = CustomPromptView::new( + title.to_string(), + "Type a name and press Enter".to_string(), + /*initial_text*/ existing_name.unwrap_or_default().to_string(), + /*context_label*/ None, + Box::new(move |name: String| { + let Some(name) = crate::legacy_core::util::normalize_thread_name(&name) else { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event("Thread name cannot be empty.".to_string()), + ))); + return; + }; + tx.set_thread_name(name); + }), + ); + + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) fn ensure_thread_rename_allowed(&mut self) -> bool { + match self.thread_rename_block_message.clone() { + Some(message) => { + self.add_error_message(message); + false + } + None => true, + } + } + + pub(crate) fn handle_paste(&mut self, text: String) { + self.bottom_pane.handle_paste(text); + self.refresh_plan_mode_nudge(); + } + + // Returns true if caller should skip rendering this frame (a future frame is scheduled). + pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { + if self.bottom_pane.flush_paste_burst_if_due() { + self.refresh_plan_mode_nudge(); + // A paste just flushed; request an immediate redraw and skip this frame. + self.request_redraw(); + true + } else if self.bottom_pane.is_in_paste_burst() { + // While capturing a burst, schedule a follow-up tick and skip this frame + // to avoid redundant renders between ticks. + frame_requester.schedule_frame_in( + crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), + ); + true + } else { + false + } + } + + /// Handles a Ctrl+C press at the chat-widget layer. + /// + /// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom + /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut + /// is armed. + /// + /// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the + /// first press always stops live voice, even when the composer contains the recording meter. + /// + /// When the double-press quit shortcut is enabled, pressing the same shortcut again before + /// expiry requests a shutdown-first quit. + fn on_ctrl_c(&mut self) { + let key = key_hint::ctrl(KeyCode::Char('c')); + if self.realtime_conversation.is_live() { + self.bottom_pane.clear_quit_shortcut_hint(); + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.stop_realtime_conversation_from_ui(); + return; + } + let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); + if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { + if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { + if modal_or_popup_active { + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.bottom_pane.clear_quit_shortcut_hint(); + } else { + self.arm_quit_shortcut(key); + } + } + return; + } + + if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { + if self.is_cancellable_work_active() { + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.bottom_pane.clear_quit_shortcut_hint(); + self.submit_op(AppCommand::interrupt()); + } else { + self.request_quit_without_confirmation(); + } + return; + } + + if self.quit_shortcut_active_for(key) { + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.request_quit_without_confirmation(); + return; + } + + self.arm_quit_shortcut(key); + + if self.is_cancellable_work_active() { + self.submit_op(AppCommand::interrupt()); + } + } + + /// Handles a Ctrl+D press at the chat-widget layer. + /// + /// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active. + /// Otherwise it should be routed to the active view and not attempt to quit. + fn on_ctrl_d(&mut self) -> bool { + let key = key_hint::ctrl(KeyCode::Char('d')); + if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { + if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() + { + return false; + } + + self.request_quit_without_confirmation(); + return true; + } + + if self.quit_shortcut_active_for(key) { + self.quit_shortcut_expires_at = None; + self.quit_shortcut_key = None; + self.request_quit_without_confirmation(); + return true; + } + + if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() { + return false; + } + + self.arm_quit_shortcut(key); + true + } + + /// True if `key` matches the armed quit shortcut and the window has not expired. + fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool { + self.quit_shortcut_key == Some(key) + && self + .quit_shortcut_expires_at + .is_some_and(|expires_at| Instant::now() < expires_at) + } + + /// Arm the double-press quit shortcut and show the footer hint. + /// + /// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since + /// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether + /// quitting is currently allowed, while delegating rendering to `BottomPane`. + pub(super) fn arm_quit_shortcut(&mut self, key: KeyBinding) { + self.quit_shortcut_expires_at = Instant::now() + .checked_add(QUIT_SHORTCUT_TIMEOUT) + .or_else(|| Some(Instant::now())); + self.quit_shortcut_key = Some(key); + self.bottom_pane.show_quit_shortcut_hint(key); + } + + // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. + fn is_cancellable_work_active(&self) -> bool { + self.bottom_pane.is_task_running() || self.review.is_review_mode + } +} diff --git a/codex-rs/tui/src/chatwidget/notifications.rs b/codex-rs/tui/src/chatwidget/notifications.rs new file mode 100644 index 000000000..2084956b2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/notifications.rs @@ -0,0 +1,128 @@ +//! Desktop notification coalescing for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(super) fn notify(&mut self, notification: Notification) { + if !notification.allowed_for(&self.config.tui_notifications.notifications) { + return; + } + if let Some(existing) = self.pending_notification.as_ref() + && existing.priority() > notification.priority() + { + return; + } + self.pending_notification = Some(notification); + self.request_redraw(); + } + + pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { + if let Some(notif) = self.pending_notification.take() { + tui.notify(notif.display()); + } + } +} + +#[derive(Debug)] +pub(super) enum Notification { + AgentTurnComplete { response: String }, + ExecApprovalRequested { command: String }, + EditApprovalRequested { cwd: PathBuf, changes: Vec }, + ElicitationRequested { server_name: String }, + PlanModePrompt { title: String }, +} + +impl Notification { + pub(super) fn display(&self) -> String { + match self { + Notification::AgentTurnComplete { response } => { + Notification::agent_turn_preview(response) + .unwrap_or_else(|| "Agent turn complete".to_string()) + } + Notification::ExecApprovalRequested { command } => { + format!( + "Approval requested: {}", + truncate_text(command, /*max_graphemes*/ 30) + ) + } + Notification::EditApprovalRequested { cwd, changes } => { + format!( + "Codex wants to edit {}", + if changes.len() == 1 { + #[allow(clippy::unwrap_used)] + display_path_for(changes.first().unwrap(), cwd) + } else { + format!("{} files", changes.len()) + } + ) + } + Notification::ElicitationRequested { server_name } => { + format!("Approval requested by {server_name}") + } + Notification::PlanModePrompt { title } => { + format!("Plan mode prompt: {title}") + } + } + } + + fn type_name(&self) -> &str { + match self { + Notification::AgentTurnComplete { .. } => "agent-turn-complete", + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } + | Notification::ElicitationRequested { .. } => "approval-requested", + Notification::PlanModePrompt { .. } => "plan-mode-prompt", + } + } + + fn priority(&self) -> u8 { + match self { + Notification::AgentTurnComplete { .. } => 0, + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } + | Notification::ElicitationRequested { .. } + | Notification::PlanModePrompt { .. } => 1, + } + } + + pub(super) fn allowed_for(&self, settings: &Notifications) -> bool { + match settings { + Notifications::Enabled(enabled) => *enabled, + Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), + } + } + + pub(super) fn agent_turn_preview(response: &str) -> Option { + let mut normalized = String::new(); + for part in response.split_whitespace() { + if !normalized.is_empty() { + normalized.push(' '); + } + normalized.push_str(part); + } + let trimmed = normalized.trim(); + if trimmed.is_empty() { + None + } else { + Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) + } + } + + pub(super) fn user_input_request_summary( + questions: &[codex_app_server_protocol::ToolRequestUserInputQuestion], + ) -> Option { + let first_question = questions.first()?; + let summary = if first_question.header.trim().is_empty() { + first_question.question.trim() + } else { + first_question.header.trim() + }; + if summary.is_empty() { + None + } else { + Some(truncate_text(summary, /*max_graphemes*/ 30)) + } + } +} + +const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs new file mode 100644 index 000000000..31c2d1dbf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -0,0 +1,129 @@ +//! Render composition for the main chat widget surface. + +use super::*; + +impl ChatWidget { + pub(super) fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); + let active_cell_renderable = match &self.transcript.active_cell { + Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell.as_ref(), + top: 1, + right: active_cell_right_reserve, + })), + None => RenderableItem::Owned(Box::new(())), + }; + let active_hook_cell_renderable = match &self.active_hook_cell { + Some(cell) if cell.should_render() => { + RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell, + top: 1, + right: active_cell_right_reserve, + })) + } + _ => RenderableItem::Owned(Box::new(())), + }; + let mut flex = FlexRenderable::new(); + flex.push(/*flex*/ 1, active_cell_renderable); + flex.push(/*flex*/ 0, active_hook_cell_renderable); + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable { + bottom_pane: &self.bottom_pane, + right_reserve: active_cell_right_reserve, + })) + .inset(Insets::tlbr( + /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, + )), + ); + RenderableItem::Owned(Box::new(flex)) + } +} + +struct BottomPaneComposerReserveRenderable<'a> { + bottom_pane: &'a BottomPane, + right_reserve: u16, +} + +impl Renderable for BottomPaneComposerReserveRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.bottom_pane + .render_with_composer_right_reserve(area, buf, self.right_reserve); + } + + fn desired_height(&self, width: u16) -> u16 { + self.bottom_pane + .desired_height_with_composer_right_reserve(width, self.right_reserve) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.bottom_pane + .cursor_pos_with_composer_right_reserve(area, self.right_reserve) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.bottom_pane + .cursor_style_with_composer_right_reserve(area, self.right_reserve) + } +} + +struct TranscriptAreaRenderable<'a> { + child: &'a dyn HistoryCell, + top: u16, + right: u16, +} + +impl Renderable for TranscriptAreaRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + let area = self.child_area(area); + let lines = self.child.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + let y = if area.height == 0 { + 0 + } else { + let overflow = paragraph + .line_count(area.width) + .saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Clear.render(area, buf); + paragraph.scroll((y, 0)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let child_width = width.saturating_sub(self.right).max(1); + HistoryCell::desired_height(self.child, child_width) + self.top + } +} + +impl TranscriptAreaRenderable<'_> { + fn child_area(&self, area: Rect) -> Rect { + let y = area.y.saturating_add(self.top); + let height = area.height.saturating_sub(self.top); + Rect::new( + area.x, + y, + area.width.saturating_sub(self.right).max(1), + height, + ) + } +} + +impl Renderable for ChatWidget { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_renderable().render(area, buf); + self.last_rendered_width.set(Some(area.width as usize)); + } + + 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) + } +} diff --git a/codex-rs/tui/src/chatwidget/review_popups.rs b/codex-rs/tui/src/chatwidget/review_popups.rs new file mode 100644 index 000000000..b069649ef --- /dev/null +++ b/codex-rs/tui/src/chatwidget/review_popups.rs @@ -0,0 +1,182 @@ +//! Review preset selection and custom review prompt surfaces. + +use super::*; + +impl ChatWidget { + pub(crate) fn open_review_popup(&mut self) { + let mut items: Vec = Vec::new(); + + items.push(SelectionItem { + name: "Review against a base branch".to_string(), + description: Some("(PR Style)".into()), + actions: vec![Box::new({ + let cwd = self.config.cwd.to_path_buf(); + move |tx| { + tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Review uncommitted changes".to_string(), + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.review(ReviewTarget::UncommittedChanges); + })], + dismiss_on_select: true, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Review a commit".to_string(), + actions: vec![Box::new({ + let cwd = self.config.cwd.to_path_buf(); + move |tx| { + tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); + } + })], + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Custom review instructions".to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReviewCustomPrompt); + })], + dismiss_on_select: false, + dismiss_parent_on_child_accept: true, + ..Default::default() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a review preset".into()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { + let branches = local_git_branches(cwd).await; + let current_branch = current_branch_name(cwd) + .await + .unwrap_or_else(|| "(detached HEAD)".to_string()); + let mut items: Vec = Vec::with_capacity(branches.len()); + + for option in branches { + let branch = option.clone(); + items.push(SelectionItem { + name: format!("{current_branch} -> {branch}"), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.review(ReviewTarget::BaseBranch { + branch: branch.clone(), + }); + })], + dismiss_on_select: true, + search_value: Some(option), + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a base branch".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search branches".to_string()), + ..Default::default() + }); + } + + pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { + let commits = recent_commits(cwd, /*limit*/ 100).await; + + let mut items: Vec = Vec::with_capacity(commits.len()); + for entry in commits { + let subject = entry.subject.clone(); + let sha = entry.sha.clone(); + let search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.review(ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }); + })], + dismiss_on_select: true, + search_value: Some(search_val), + ..Default::default() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a commit to review".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + ..Default::default() + }); + } + + pub(crate) fn show_review_custom_prompt(&mut self) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Custom review instructions".to_string(), + "Type instructions and press Enter".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ None, + Box::new(move |prompt: String| { + let trimmed = prompt.trim().to_string(); + if trimmed.is_empty() { + return; + } + tx.review(ReviewTarget::Custom { + instructions: trimmed, + }); + }), + ); + self.bottom_pane.show_view(Box::new(view)); + } +} + +#[cfg(test)] +pub(crate) fn show_review_commit_picker_with_entries( + chat: &mut ChatWidget, + entries: Vec, +) { + let mut items: Vec = Vec::with_capacity(entries.len()); + for entry in entries { + let subject = entry.subject.clone(); + let sha = entry.sha.clone(); + let search_val = format!("{subject} {sha}"); + + items.push(SelectionItem { + name: subject.clone(), + actions: vec![Box::new(move |tx3: &AppEventSender| { + tx3.review(ReviewTarget::Commit { + sha: sha.clone(), + title: Some(subject.clone()), + }); + })], + dismiss_on_select: true, + search_value: Some(search_val), + ..Default::default() + }); + } + + chat.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select a commit to review".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search commits".to_string()), + ..Default::default() + }); +} diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs new file mode 100644 index 000000000..892e43f3e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -0,0 +1,207 @@ +//! Session configuration and thread-header orchestration for `ChatWidget`. + +use super::*; + +impl ChatWidget { + fn on_session_configured_with_display_and_fork_parent_title( + &mut self, + session: ThreadSessionState, + display: SessionConfiguredDisplay, + fork_parent_title: Option, + ) { + self.transcript.reset_copy_history(); + let history_metadata = session.message_history.unwrap_or_default(); + self.bottom_pane.set_history_metadata( + session.thread_id, + history_metadata.log_id, + history_metadata.entry_count, + ); + self.set_skills(/*skills*/ None); + self.session_network_proxy = session.network_proxy.clone(); + let previous_thread_id = self.thread_id; + self.thread_id = Some(session.thread_id); + if previous_thread_id != self.thread_id { + self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); + } + self.refresh_plan_mode_nudge(); + self.turn_lifecycle.reset_thread(); + self.thread_name = session.thread_name.clone(); + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.update_collaboration_mode_indicator(); + self.forked_from = session.forked_from_id; + self.current_rollout_path = session.rollout_path.clone(); + self.current_cwd = Some(session.cwd.to_path_buf()); + self.config.cwd = session.cwd.clone(); + self.effective_service_tier = session.service_tier.clone(); + if let Err(err) = self + .config + .permissions + .approval_policy + .set(session.approval_policy.to_core()) + { + tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured"); + self.config.permissions.approval_policy = + Constrained::allow_only(session.approval_policy.to_core()); + } + let permission_sync = self + .config + .permissions + .set_permission_profile_with_active_profile( + session.permission_profile.clone(), + session.active_permission_profile.clone(), + ); + if let Err(err) = permission_sync { + tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); + self.config.permissions.permission_profile = + Constrained::allow_only(session.permission_profile.clone()); + self.config.permissions.active_permission_profile = + session.active_permission_profile.clone(); + } + self.config.approvals_reviewer = session.approvals_reviewer; + self.status_line_project_root_name_cache = None; + let forked_from_id = session.forked_from_id; + let model_for_header = session.model.clone(); + self.session_header.set_model(&model_for_header); + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(session.reasoning_effort), + /*developer_instructions*/ None, + ); + if let Some(mask) = self.active_collaboration_mask.as_mut() { + mask.model = Some(model_for_header.clone()); + mask.reasoning_effort = Some(session.reasoning_effort); + } + self.refresh_model_display(); + self.refresh_status_surfaces(); + self.sync_service_tier_commands(); + self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); + self.sync_goal_command_enabled(); + self.refresh_plugin_mentions(); + if display == SessionConfiguredDisplay::Normal { + let startup_tooltip_override = self.startup_tooltip_override.take(); + let show_fast_status = self + .should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); + let session_info_cell = history_cell::new_session_info( + &self.config, + &model_for_header, + &session, + self.show_welcome_banner, + startup_tooltip_override, + self.plan_type, + show_fast_status, + ); + self.apply_session_info_cell(session_info_cell); + } else if self + .transcript + .active_cell + .as_ref() + .is_some_and(|cell| cell.as_any().is::()) + { + self.transcript.active_cell = None; + self.bump_active_cell_revision(); + } + self.transcript.saw_copy_source_this_turn = false; + self.refresh_skills_for_current_cwd(/*force_reload*/ true); + if self.connectors_enabled() { + self.prefetch_connectors(); + } + if let Some(user_message) = self.initial_user_message.take() { + if self.suppress_initial_user_message_submit { + self.initial_user_message = Some(user_message); + } else { + self.submit_user_message(user_message); + } + } + if display == SessionConfiguredDisplay::Normal + && let Some(forked_from_id) = forked_from_id + { + self.emit_forked_thread_event(forked_from_id, fork_parent_title); + } + if !self.suppress_session_configured_redraw { + self.request_redraw(); + } + } + + pub(crate) fn handle_thread_session(&mut self, session: ThreadSessionState) { + self.instruction_source_paths = session.instruction_source_paths.clone(); + let fork_parent_title = session.fork_parent_title.clone(); + self.on_session_configured_with_display_and_fork_parent_title( + session, + SessionConfiguredDisplay::Normal, + fork_parent_title, + ); + } + + pub(crate) fn handle_thread_session_quiet(&mut self, session: ThreadSessionState) { + self.instruction_source_paths = session.instruction_source_paths.clone(); + self.on_session_configured_with_display_and_fork_parent_title( + session, + SessionConfiguredDisplay::Quiet, + /*fork_parent_title*/ None, + ); + } + + pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) { + self.instruction_source_paths = session.instruction_source_paths.clone(); + let fork_parent_title = session.fork_parent_title.clone(); + self.on_session_configured_with_display_and_fork_parent_title( + session, + SessionConfiguredDisplay::SideConversation, + fork_parent_title, + ); + } + + pub(super) fn emit_forked_thread_event( + &mut self, + forked_from_id: ThreadId, + fork_parent_title: Option, + ) { + let forked_from_id_text = forked_from_id.to_string(); + let line: Line<'static> = if let Some(name) = fork_parent_title + && !name.trim().is_empty() + { + vec![ + "• ".dim(), + "Thread forked from ".into(), + name.cyan(), + " (".into(), + forked_from_id_text.cyan(), + ")".into(), + ] + .into() + } else { + vec![ + "• ".dim(), + "Thread forked from ".into(), + forked_from_id_text.cyan(), + ] + .into() + }; + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + PlainHistoryCell::new(vec![line]), + ))); + } + + pub(super) fn on_thread_name_updated( + &mut self, + thread_id: ThreadId, + thread_name: Option, + ) { + if self.thread_id == Some(thread_id) { + if let Some(name) = thread_name.as_deref() { + let cell = Self::rename_confirmation_cell(name, self.thread_id); + self.add_boxed_history(Box::new(cell)); + } + self.thread_name = thread_name; + self.refresh_status_surfaces(); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + } + + pub(super) fn set_skills(&mut self, skills: Option>) { + self.bottom_pane.set_skills(skills); + } +}