diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c07d49c9146f..b73459c6ff96 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -337,6 +337,9 @@ mod ide_context; use self::ide_context::IdeContextState; mod input_queue; use self::input_queue::InputQueueState; +mod input_flow; +mod input_restore; +mod input_submission; mod interrupts; use self::interrupts::InterruptManager; mod keymap_picker; @@ -1337,16 +1340,6 @@ impl ChatWidget { } } - pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { - self.suppress_initial_user_message_submit = suppressed; - } - - pub(crate) fn submit_initial_user_message_if_pending(&mut self) { - if let Some(user_message) = self.initial_user_message.take() { - self.submit_user_message(user_message); - } - } - 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(); @@ -1851,85 +1844,6 @@ impl ChatWidget { self.input_queue.has_queued_follow_up_messages() } - fn pop_next_queued_user_message( - &mut self, - ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { - if self.input_queue.rejected_steers_queue.is_empty() { - self.input_queue - .queued_user_messages - .pop_front() - .map(|user_message| { - let history_record = self - .input_queue - .queued_user_message_history_records - .pop_front() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - (user_message, history_record) - }) - } else { - let rejected_messages = self - .input_queue - .rejected_steers_queue - .drain(..) - .collect::>(); - let mut history_records = self - .input_queue - .rejected_steer_history_records - .drain(..) - .collect::>(); - history_records.resize( - rejected_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - let (message, history_record) = merge_user_messages_with_history_record( - rejected_messages - .into_iter() - .zip(history_records) - .collect::>(), - ); - Some((QueuedUserMessage::from(message), history_record)) - } - } - - fn pop_latest_queued_user_message(&mut self) -> Option { - if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { - let history_record = self - .input_queue - .queued_user_message_history_records - .pop_back() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - Some(user_message_for_restore( - user_message.into_user_message(), - &history_record, - )) - } else { - let user_message = self.input_queue.rejected_steers_queue.pop_back()?; - let history_record = self - .input_queue - .rejected_steer_history_records - .pop_back() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - Some(user_message_for_restore(user_message, &history_record)) - } - } - - pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { - let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else { - tracing::warn!( - "received active-turn-not-steerable error without a matching pending steer" - ); - return false; - }; - self.input_queue - .rejected_steers_queue - .push_back(pending_steer.user_message); - self.input_queue - .rejected_steer_history_records - .push_back(pending_steer.history_record); - self.refresh_pending_input_preview(); - true - } - fn handle_app_server_steer_rejected_error( &mut self, codex_error_info: &AppServerCodexErrorInfo, @@ -2337,292 +2251,6 @@ impl ChatWidget { } } - /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, - /// or review completion. - /// When there are queued user messages, restore them into the composer - /// separated by newlines rather than auto‑submitting the next one. - fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { - // Finalize, log a gentle prompt, and clear running state. - self.finalize_turn(); - let send_pending_steers_immediately = - self.input_queue.submit_pending_steers_after_interrupt; - self.input_queue.submit_pending_steers_after_interrupt = false; - if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { - if send_pending_steers_immediately { - self.add_to_history(history_cell::new_info_event( - "Model interrupted to submit steer instructions.".to_owned(), - /*hint*/ None, - )); - } else { - self.add_to_history(history_cell::new_error_event( - self.interrupted_turn_message(reason), - )); - } - } - - // The server has already discarded pending input by the time the - // interrupted turn reaches the UI, so any unacknowledged steers still - // tracked here must be restored locally instead of waiting for a later commit. - if send_pending_steers_immediately { - let pending_steers = self - .input_queue - .pending_steers - .drain(..) - .map(|pending| (pending.user_message, pending.history_record)) - .collect::>(); - if !pending_steers.is_empty() { - let (user_message, history_record) = - merge_user_messages_with_history_record(pending_steers); - self.submit_user_message_with_history_record(user_message, history_record); - } else if let Some(combined) = self.drain_pending_messages_for_restore() { - self.restore_user_message_to_composer(combined); - } - } else if let Some(combined) = self.drain_pending_messages_for_restore() { - self.restore_user_message_to_composer(combined); - } - self.refresh_pending_input_preview(); - - self.request_redraw(); - } - - /// Merge pending steers, queued drafts, and the current composer state into a single message. - /// - /// Each pending message numbers attachments from `[Image #1]` relative to its own remote - /// images. When we concatenate multiple messages after interrupt, we must renumber local-image - /// placeholders in a stable order and rebase text element byte ranges so the restored composer - /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to - /// restore. - fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.input_queue.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { - return None; - } - - let existing_message = UserMessage { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), - }; - - let rejected_messages = self - .input_queue - .rejected_steers_queue - .drain(..) - .collect::>(); - let mut rejected_history_records = self - .input_queue - .rejected_steer_history_records - .drain(..) - .collect::>(); - rejected_history_records.resize( - rejected_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - let mut to_merge: Vec = rejected_messages - .into_iter() - .zip(rejected_history_records.iter()) - .map(|(message, history_record)| user_message_for_restore(message, history_record)) - .collect(); - to_merge.extend( - self.input_queue - .pending_steers - .drain(..) - .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), - ); - let queued_messages = self - .input_queue - .queued_user_messages - .drain(..) - .collect::>(); - let mut queued_history_records = self - .input_queue - .queued_user_message_history_records - .drain(..) - .collect::>(); - queued_history_records.resize( - queued_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - to_merge.extend( - queued_messages - .into_iter() - .zip(queued_history_records.iter()) - .map(|(message, history_record)| { - user_message_for_restore(message.into_user_message(), history_record) - }), - ); - if !existing_message.text.is_empty() - || !existing_message.local_images.is_empty() - || !existing_message.remote_image_urls.is_empty() - { - to_merge.push(existing_message); - } - - Some(merge_user_messages(to_merge)) - } - - pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { - let UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - } = user_message; - let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); - self.set_remote_image_urls(remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - text, - text_elements, - local_image_paths, - mention_bindings, - ); - } - - pub(crate) fn capture_thread_input_state(&self) -> Option { - let composer = ThreadComposerState { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), - pending_pastes: self.bottom_pane.composer_pending_pastes(), - }; - Some(ThreadInputState { - composer: composer.has_content().then_some(composer), - pending_steers: self - .input_queue - .pending_steers - .iter() - .map(|pending| pending.user_message.clone()) - .collect(), - pending_steer_history_records: self - .input_queue - .pending_steers - .iter() - .map(|pending| pending.history_record.clone()) - .collect(), - pending_steer_compare_keys: self - .input_queue - .pending_steers - .iter() - .map(|pending| pending.compare_key.clone()) - .collect(), - rejected_steers_queue: self.input_queue.rejected_steers_queue.clone(), - rejected_steer_history_records: self.input_queue.rejected_steer_history_records.clone(), - queued_user_messages: self.input_queue.queued_user_messages.clone(), - queued_user_message_history_records: self - .input_queue - .queued_user_message_history_records - .clone(), - user_turn_pending_start: self.input_queue.user_turn_pending_start, - current_collaboration_mode: self.current_collaboration_mode.clone(), - active_collaboration_mask: self.active_collaboration_mask.clone(), - task_running: self.bottom_pane.is_task_running(), - agent_turn_running: self.turn_lifecycle.agent_turn_running, - }) - } - - pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { - let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); - if let Some(input_state) = input_state { - self.current_collaboration_mode = input_state.current_collaboration_mode; - self.active_collaboration_mask = input_state.active_collaboration_mask; - self.turn_lifecycle - .restore_running(input_state.agent_turn_running, Instant::now()); - self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; - self.update_collaboration_mode_indicator(); - self.refresh_model_dependent_surfaces(); - if let Some(composer) = input_state.composer { - let local_image_paths = composer - .local_images - .into_iter() - .map(|img| img.path) - .collect(); - self.set_remote_image_urls(composer.remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - composer.text, - composer.text_elements, - local_image_paths, - composer.mention_bindings, - ); - self.bottom_pane - .set_composer_pending_pastes(composer.pending_pastes); - } else { - self.set_remote_image_urls(Vec::new()); - self.bottom_pane.set_composer_text_with_mention_bindings( - String::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - self.bottom_pane.set_composer_pending_pastes(Vec::new()); - } - let mut pending_steer_history_records = input_state.pending_steer_history_records; - pending_steer_history_records.resize( - input_state.pending_steers.len(), - UserMessageHistoryRecord::UserMessageText, - ); - let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; - self.input_queue.pending_steers = input_state - .pending_steers - .into_iter() - .zip(pending_steer_history_records) - .map(|(user_message, history_record)| PendingSteer { - compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| { - PendingSteerCompareKey { - message: user_message.text.clone(), - image_count: user_message.local_images.len() - + user_message.remote_image_urls.len(), - } - }), - history_record, - user_message, - }) - .collect(); - self.input_queue.rejected_steers_queue = input_state.rejected_steers_queue; - self.input_queue.rejected_steer_history_records = - input_state.rejected_steer_history_records; - self.input_queue.rejected_steer_history_records.resize( - self.input_queue.rejected_steers_queue.len(), - UserMessageHistoryRecord::UserMessageText, - ); - self.input_queue.queued_user_messages = input_state.queued_user_messages; - self.input_queue.queued_user_message_history_records = - input_state.queued_user_message_history_records; - self.input_queue.queued_user_message_history_records.resize( - self.input_queue.queued_user_messages.len(), - UserMessageHistoryRecord::UserMessageText, - ); - } else { - self.turn_lifecycle - .restore_running(/*running*/ false, Instant::now()); - self.input_queue.clear(); - self.set_remote_image_urls(Vec::new()); - self.bottom_pane.set_composer_text_with_mention_bindings( - String::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - self.bottom_pane.set_composer_pending_pastes(Vec::new()); - } - self.turn_lifecycle - .restore_running(self.turn_lifecycle.agent_turn_running, Instant::now()); - self.update_task_running_state(); - if restored_task_running && !self.bottom_pane.is_task_running() { - self.bottom_pane.set_task_running(/*running*/ true); - self.refresh_status_surfaces(); - } - self.refresh_pending_input_preview(); - self.request_redraw(); - } - - pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { - self.input_queue.suppress_queue_autosend = suppressed; - } - fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.transcript.saw_plan_update_this_turn = true; let total = update.plan.len(); @@ -4509,84 +4137,8 @@ impl ChatWidget { } _ => { let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted { - text, - text_elements, - } => { - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings: self - .bottom_pane - .take_recent_submission_mention_bindings(), - }; - if user_message.text.is_empty() - && user_message.local_images.is_empty() - && user_message.remote_image_urls.is_empty() - { - return; - } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); - if should_submit_now { - if self.only_user_shell_commands_running() - && !user_message.text.starts_with('!') - { - self.queue_user_message(user_message); - return; - } - // Submitted is emitted when user submits. - // Reset any reasoning header only when we are actually submitting a turn. - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - self.submit_user_message(user_message); - } else { - self.queue_user_message(user_message); - } - } - InputResult::Queued { - text, - text_elements, - action, - } => { - let local_images = self - .bottom_pane - .take_recent_submission_images_with_placeholders(); - let remote_image_urls = self.take_remote_image_urls(); - let user_message = UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings: self - .bottom_pane - .take_recent_submission_mention_bindings(), - }; - self.queue_user_message_with_options(user_message, action); - } - InputResult::Command(cmd) => { - self.handle_slash_command_dispatch(cmd); - } - InputResult::ServiceTierCommand(command) => { - self.handle_service_tier_command_dispatch(command); - } - InputResult::CommandWithArgs(cmd, args, text_elements) => { - self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); - } - InputResult::None => {} - } - if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { - self.maybe_send_next_queued_input(); - } - self.refresh_plan_mode_nudge(); + let input_result = self.bottom_pane.handle_key_event(key_event); + self.handle_composer_input_result(input_result, had_modal_or_popup); } } } @@ -4856,444 +4408,6 @@ impl ChatWidget { } } - fn queue_user_message(&mut self, user_message: UserMessage) { - self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); - } - - fn queue_user_message_with_options( - &mut self, - user_message: UserMessage, - action: QueuedInputAction, - ) { - if !self.is_session_configured() || self.is_user_turn_pending_or_running() { - self.input_queue - .queued_user_messages - .push_back(QueuedUserMessage::new(user_message, action)); - self.input_queue - .queued_user_message_history_records - .push_back(UserMessageHistoryRecord::UserMessageText); - self.refresh_pending_input_preview(); - } else { - self.submit_user_message(user_message); - } - } - - fn submit_shell_command(&mut self, command: &str) -> QueueDrain { - let cmd = command.trim(); - if cmd.is_empty() { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - USER_SHELL_COMMAND_HELP_TITLE.to_string(), - Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), - ), - ))); - QueueDrain::Continue - } else { - self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); - QueueDrain::Stop - } - } - - fn submit_shell_command_with_history( - &mut self, - command: &str, - history_text: &str, - ) -> QueueDrain { - let drain = self.submit_shell_command(command); - if drain == QueueDrain::Stop { - self.append_message_history_entry(history_text.to_string()); - } - drain - } - - fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { - match user_message.text.strip_prefix('!') { - Some(command) => { - let history_text = user_message.text.clone(); - self.submit_shell_command_with_history(command, &history_text) - } - None => { - self.submit_user_message(user_message); - QueueDrain::Stop - } - } - } - - fn submit_user_message(&mut self, user_message: UserMessage) { - let _accepted = self.submit_user_message_with_history_record( - user_message, - UserMessageHistoryRecord::UserMessageText, - ); - } - - fn submit_user_message_with_history_record( - &mut self, - user_message: UserMessage, - history_record: UserMessageHistoryRecord, - ) -> bool { - self.submit_user_message_with_history_and_shell_escape_policy( - user_message, - history_record, - ShellEscapePolicy::Allow, - ) - .0 - } - - fn submit_user_message_with_shell_escape_policy( - &mut self, - user_message: UserMessage, - shell_escape_policy: ShellEscapePolicy, - ) -> Option { - self.submit_user_message_with_history_and_shell_escape_policy( - user_message, - UserMessageHistoryRecord::UserMessageText, - shell_escape_policy, - ) - .1 - } - - fn submit_user_message_with_history_and_shell_escape_policy( - &mut self, - user_message: UserMessage, - history_record: UserMessageHistoryRecord, - shell_escape_policy: ShellEscapePolicy, - ) -> (bool, Option) { - if !self.is_session_configured() { - tracing::warn!("cannot submit user message before session is configured; queueing"); - self.input_queue - .queued_user_messages - .push_front(QueuedUserMessage::from(user_message)); - self.input_queue - .queued_user_message_history_records - .push_front(history_record); - self.refresh_pending_input_preview(); - return (true, None); - } - if user_message.text.is_empty() - && user_message.local_images.is_empty() - && user_message.remote_image_urls.is_empty() - { - return (false, None); - } - if (!user_message.local_images.is_empty() || !user_message.remote_image_urls.is_empty()) - && !self.current_model_supports_images() - { - let UserMessage { - text, - text_elements, - local_images, - mention_bindings, - remote_image_urls, - } = user_message_for_restore(user_message, &history_record); - self.restore_blocked_image_submission( - text, - text_elements, - local_images, - mention_bindings, - remote_image_urls, - ); - return (false, None); - } - let UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - } = user_message; - - let render_in_history = !self.turn_lifecycle.agent_turn_running; - let mut items: Vec = Vec::new(); - - // Special-case: "!cmd" executes a local shell command instead of sending to the model. - if shell_escape_policy == ShellEscapePolicy::Allow - && let Some(stripped) = text.strip_prefix('!') - { - let app_command = match self.submit_shell_command_with_history(stripped, &text) { - QueueDrain::Continue => None, - QueueDrain::Stop => Some(AppCommand::run_user_shell_command( - stripped.trim().to_string(), - )), - }; - return (app_command.is_some(), app_command); - } - - for image_url in &remote_image_urls { - items.push(UserInput::Image { - url: image_url.clone(), - }); - } - - for image in &local_images { - items.push(UserInput::LocalImage { - path: image.path.clone(), - }); - } - - if !text.is_empty() { - items.push(UserInput::Text { - text: text.clone(), - text_elements: app_server_text_elements(&text_elements), - }); - } - - let mentions = collect_tool_mentions(&text, &HashMap::new()); - let bound_names: HashSet = mention_bindings - .iter() - .map(|binding| binding.mention.clone()) - .collect(); - let mut skill_names_lower: HashSet = HashSet::new(); - let mut selected_skill_paths: HashSet = HashSet::new(); - let mut selected_plugin_ids: HashSet = HashSet::new(); - - if let Some(skills) = self.bottom_pane.skills() { - skill_names_lower = skills - .iter() - .map(|skill| skill.name.to_ascii_lowercase()) - .collect(); - - for binding in &mention_bindings { - let path = binding - .path - .strip_prefix("skill://") - .unwrap_or(binding.path.as_str()); - let path = Path::new(path); - if let Some(skill) = skills - .iter() - .find(|skill| skill.path_to_skills_md.as_path() == path) - && selected_skill_paths.insert(skill.path_to_skills_md.clone()) - { - items.push(UserInput::Skill { - name: skill.name.clone(), - path: skill.path_to_skills_md.to_path_buf(), - }); - } - } - - let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); - for skill in skill_mentions { - if bound_names.contains(skill.name.as_str()) - || !selected_skill_paths.insert(skill.path_to_skills_md.clone()) - { - continue; - } - items.push(UserInput::Skill { - name: skill.name.clone(), - path: skill.path_to_skills_md.to_path_buf(), - }); - } - } - - if let Some(plugins) = self.plugins_for_mentions() { - for binding in &mention_bindings { - let Some(plugin_config_name) = binding - .path - .strip_prefix("plugin://") - .filter(|id| !id.is_empty()) - else { - continue; - }; - if !selected_plugin_ids.insert(plugin_config_name.to_string()) { - continue; - } - if let Some(plugin) = plugins - .iter() - .find(|plugin| plugin.config_name == plugin_config_name) - { - items.push(UserInput::Mention { - name: plugin.display_name.clone(), - path: binding.path.clone(), - }); - } - } - } - - let mut selected_app_ids: HashSet = HashSet::new(); - if let Some(apps) = self.connectors_for_mentions() { - for binding in &mention_bindings { - let Some(app_id) = binding - .path - .strip_prefix("app://") - .filter(|id| !id.is_empty()) - else { - continue; - }; - if !selected_app_ids.insert(app_id.to_string()) { - continue; - } - if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { - items.push(UserInput::Mention { - name: app.name.clone(), - path: binding.path.clone(), - }); - } - } - - let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); - for app in app_mentions { - let slug = codex_connectors::metadata::connector_mention_slug(&app); - if bound_names.contains(&slug) || !selected_app_ids.insert(app.id.clone()) { - continue; - } - let app_id = app.id.as_str(); - items.push(UserInput::Mention { - name: app.name.clone(), - path: format!("app://{app_id}"), - }); - } - } - - let effective_mode = self.effective_collaboration_mode(); - if effective_mode.model().trim().is_empty() { - self.add_error_message( - "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), - ); - self.restore_user_message_to_composer(user_message_for_restore( - UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - }, - &history_record, - )); - return (false, None); - } - - self.maybe_apply_ide_context(&mut items); - - let collaboration_mode = if self.collaboration_modes_enabled() { - self.active_collaboration_mask - .as_ref() - .map(|_| effective_mode.clone()) - } else { - None - }; - let pending_steer = (!render_in_history).then(|| PendingSteer { - user_message: UserMessage { - text: text.clone(), - local_images: local_images.clone(), - remote_image_urls: remote_image_urls.clone(), - text_elements: text_elements.clone(), - mention_bindings: mention_bindings.clone(), - }, - history_record: history_record.clone(), - compare_key: Self::pending_steer_compare_key_from_items(&items), - }); - let personality = self - .config - .personality - .filter(|_| self.config.features.enabled(Feature::Personality)) - .filter(|_| self.current_model_supports_personality()); - let service_tier = match self.config.service_tier.clone() { - Some(service_tier) => Some(Some(service_tier)), - None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), - None => None, - }; - let permission_profile = self.config.permissions.permission_profile(); - let op = AppCommand::user_turn( - items, - self.config.cwd.to_path_buf(), - AskForApproval::from(self.config.permissions.approval_policy.value()), - permission_profile, - effective_mode.model().to_string(), - effective_mode.reasoning_effort(), - /*summary*/ None, - service_tier, - /*final_output_json_schema*/ None, - collaboration_mode, - personality, - ); - - if !self.submit_op(op.clone()) { - return (false, None); - } - if render_in_history { - self.input_queue.user_turn_pending_start = true; - } - - // Persist the submitted text to cross-session message history. Mentions are encoded into - // placeholder syntax so recall can reconstruct the mention bindings in a future session. - let encoded_mentions = mention_bindings - .iter() - .map(|binding| LinkedMention { - mention: binding.mention.clone(), - path: binding.path.clone(), - }) - .collect::>(); - let history_text = match &history_record { - UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { - Some(encode_history_mentions(&text, &encoded_mentions)) - } - UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { - Some(encode_history_mentions(&history.text, &encoded_mentions)) - } - UserMessageHistoryRecord::UserMessageText | UserMessageHistoryRecord::Override(_) => { - None - } - }; - if let Some(history_text) = history_text { - self.append_message_history_entry(history_text); - } - - if let Some(pending_steer) = pending_steer { - self.input_queue.pending_steers.push_back(pending_steer); - self.transcript.saw_plan_item_this_turn = false; - self.refresh_pending_input_preview(); - } - - // Show replayable user content in conversation history. - let display_user_message = render_in_history.then(|| { - user_message_display_for_history( - UserMessage { - text, - local_images, - remote_image_urls, - text_elements, - mention_bindings, - }, - &history_record, - ) - }); - if let Some(display) = display_user_message { - self.on_user_message_display(display); - } - - self.transcript.needs_final_message_separator = false; - (true, Some(op)) - } - - /// Restore the blocked submission draft without losing mention resolution state. - /// - /// The blocked-image path intentionally keeps the draft in the composer so - /// users can remove attachments and retry. We must restore - /// mention bindings alongside visible text; restoring only `$name` tokens - /// makes the draft look correct while degrading mention resolution to - /// name-only heuristics on retry. - fn restore_blocked_image_submission( - &mut self, - text: String, - text_elements: Vec, - local_images: Vec, - mention_bindings: Vec, - remote_image_urls: Vec, - ) { - // Preserve the user's composed payload so they can retry after changing models. - let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect(); - self.set_remote_image_urls(remote_image_urls); - self.bottom_pane.set_composer_text_with_mention_bindings( - text, - text_elements, - local_image_paths, - mention_bindings, - ); - self.add_to_history(history_cell::new_warning_event( - self.image_inputs_not_supported_message(), - )); - self.request_redraw(); - } - /// Replay a subset of initial events into the UI to seed the transcript when /// resuming an existing session. This approximates the live event flow and /// is intentionally conservative: only safe-to-replay items are rendered to @@ -5744,71 +4858,6 @@ impl ChatWidget { } } - // If idle and there are queued inputs, submit exactly one to start the next turn. - pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { - if self.input_queue.suppress_queue_autosend { - return false; - } - if self.is_user_turn_pending_or_running() { - return false; - } - let mut submitted_follow_up = false; - while !self.is_user_turn_pending_or_running() { - let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else { - break; - }; - match queued_message.action { - QueuedInputAction::Plain => { - submitted_follow_up = self.submit_user_message_with_history_record( - queued_message.into_user_message(), - history_record, - ); - break; - } - QueuedInputAction::ParseSlash => { - let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); - if drain == QueueDrain::Stop { - submitted_follow_up = self.is_user_turn_pending_or_running(); - break; - } - } - QueuedInputAction::RunShell => { - let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); - if drain == QueueDrain::Stop { - submitted_follow_up = self.is_user_turn_pending_or_running(); - break; - } - } - } - } - // Update the list to reflect the remaining queued messages (if any). - self.refresh_pending_input_preview(); - submitted_follow_up - } - - pub(super) fn is_user_turn_pending_or_running(&self) -> bool { - self.input_queue.user_turn_pending_start || self.bottom_pane.is_task_running() - } - - fn only_user_shell_commands_running(&self) -> bool { - self.turn_lifecycle.agent_turn_running - && !self.running_commands.is_empty() - && self - .running_commands - .values() - .all(|command| command.source == ExecCommandSource::UserShell) - } - - /// Rebuild and update the bottom-pane pending-input preview. - fn refresh_pending_input_preview(&mut self) { - let preview = self.input_queue.preview(); - self.bottom_pane.set_pending_input_preview( - preview.queued_messages, - preview.pending_steers, - preview.rejected_steers, - ); - } - pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec) { self.bottom_pane.set_pending_thread_approvals(threads); } @@ -9299,40 +8348,6 @@ impl ChatWidget { self.add_info_message(message.to_string(), /*hint*/ None); } - pub(crate) fn submit_user_message_with_mode( - &mut self, - text: String, - mut collaboration_mode: CollaborationModeMask, - ) { - if collaboration_mode.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort - { - collaboration_mode.reasoning_effort = Some(Some(effort)); - } - if self.turn_lifecycle.agent_turn_running - && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) - { - self.add_error_message( - "Cannot switch collaboration mode while a turn is running.".to_string(), - ); - return; - } - self.set_collaboration_mask(collaboration_mode); - let should_queue = self.is_plan_streaming_in_tui(); - let user_message = UserMessage { - text, - local_images: Vec::new(), - remote_image_urls: Vec::new(), - text_elements: Vec::new(), - mention_bindings: Vec::new(), - }; - if should_queue { - self.queue_user_message(user_message); - } else { - self.submit_user_message(user_message); - } - } - /// True when the UI is in the regular composer state with no running task, /// no modal overlay (e.g. approvals or status indicator), and no composer popups. /// In this state Esc-Esc backtracking is enabled. @@ -9374,21 +8389,6 @@ impl ChatWidget { self.bottom_pane.remote_image_urls() } - #[cfg(test)] - pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.input_queue - .rejected_steers_queue - .iter() - .map(|message| message.text.clone()) - .chain( - self.input_queue - .queued_user_messages - .iter() - .map(|message| message.text.clone()), - ) - .collect() - } - #[cfg(test)] pub(crate) fn pending_thread_approvals(&self) -> &[String] { self.bottom_pane.pending_thread_approvals() diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs new file mode 100644 index 000000000000..704d5dcac6d3 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -0,0 +1,206 @@ +//! User input submission, queue draining, and draft restore flow for `ChatWidget`. +//! +//! The queue data itself lives in `input_queue`; this module owns the app-level +//! effects around taking composer input, submitting user turns, draining queued +//! follow-ups, and restoring draft state across interrupts or thread switches. + +use super::*; + +impl ChatWidget { + pub(super) fn handle_composer_input_result( + &mut self, + input_result: InputResult, + had_modal_or_popup: bool, + ) { + match input_result { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = self.user_message_from_submission(text, text_elements); + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return; + } + let should_submit_now = + self.is_session_configured() && !self.is_plan_streaming_in_tui(); + if should_submit_now { + if self.only_user_shell_commands_running() + && !user_message.text.starts_with('!') + { + self.queue_user_message(user_message); + return; + } + // Submitted is emitted when user submits. + // Reset any reasoning header only when we are actually submitting a turn. + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + InputResult::Queued { + text, + text_elements, + action, + } => { + let user_message = self.user_message_from_submission(text, text_elements); + self.queue_user_message_with_options(user_message, action); + } + InputResult::Command(cmd) => { + self.handle_slash_command_dispatch(cmd); + } + InputResult::ServiceTierCommand(command) => { + self.handle_service_tier_command_dispatch(command); + } + InputResult::CommandWithArgs(cmd, args, text_elements) => { + self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); + } + InputResult::None => {} + } + if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { + self.maybe_send_next_queued_input(); + } + self.refresh_plan_mode_nudge(); + } + + pub(super) fn queue_user_message(&mut self, user_message: UserMessage) { + self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); + } + + pub(super) fn queue_user_message_with_options( + &mut self, + user_message: UserMessage, + action: QueuedInputAction, + ) { + if !self.is_session_configured() || self.is_user_turn_pending_or_running() { + self.input_queue + .queued_user_messages + .push_back(QueuedUserMessage::new(user_message, action)); + self.input_queue + .queued_user_message_history_records + .push_back(UserMessageHistoryRecord::UserMessageText); + self.refresh_pending_input_preview(); + } else { + self.submit_user_message(user_message); + } + } + + /// If idle and there are queued inputs, submit exactly one to start the next turn. + pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { + if self.input_queue.suppress_queue_autosend { + return false; + } + if self.is_user_turn_pending_or_running() { + return false; + } + let mut submitted_follow_up = false; + while !self.is_user_turn_pending_or_running() { + let Some((queued_message, history_record)) = self.pop_next_queued_user_message() else { + break; + }; + match queued_message.action { + QueuedInputAction::Plain => { + submitted_follow_up = self.submit_user_message_with_history_record( + queued_message.into_user_message(), + history_record, + ); + break; + } + QueuedInputAction::ParseSlash => { + let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); + if drain == QueueDrain::Stop { + submitted_follow_up = self.is_user_turn_pending_or_running(); + break; + } + } + QueuedInputAction::RunShell => { + let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); + if drain == QueueDrain::Stop { + submitted_follow_up = self.is_user_turn_pending_or_running(); + break; + } + } + } + } + // Update the list to reflect the remaining queued messages (if any). + self.refresh_pending_input_preview(); + submitted_follow_up + } + + pub(super) fn is_user_turn_pending_or_running(&self) -> bool { + self.input_queue.user_turn_pending_start || self.bottom_pane.is_task_running() + } + + pub(super) fn only_user_shell_commands_running(&self) -> bool { + self.turn_lifecycle.agent_turn_running + && !self.running_commands.is_empty() + && self + .running_commands + .values() + .all(|command| command.source == ExecCommandSource::UserShell) + } + + /// Rebuild and update the bottom-pane pending-input preview. + pub(super) fn refresh_pending_input_preview(&mut self) { + let preview = self.input_queue.preview(); + self.bottom_pane.set_pending_input_preview( + preview.queued_messages, + preview.pending_steers, + preview.rejected_steers, + ); + } + + pub(crate) fn submit_user_message_with_mode( + &mut self, + text: String, + mut collaboration_mode: CollaborationModeMask, + ) { + if collaboration_mode.mode == Some(ModeKind::Plan) + && let Some(effort) = self.config.plan_mode_reasoning_effort + { + collaboration_mode.reasoning_effort = Some(Some(effort)); + } + if self.turn_lifecycle.agent_turn_running + && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) + { + self.add_error_message( + "Cannot switch collaboration mode while a turn is running.".to_string(), + ); + return; + } + self.set_collaboration_mask(collaboration_mode); + let should_queue = self.is_plan_streaming_in_tui(); + let user_message = UserMessage { + text, + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }; + if should_queue { + self.queue_user_message(user_message); + } else { + self.submit_user_message(user_message); + } + } + + #[cfg(test)] + pub(crate) fn queued_user_message_texts(&self) -> Vec { + self.input_queue + .rejected_steers_queue + .iter() + .map(|message| message.text.clone()) + .chain( + self.input_queue + .queued_user_messages + .iter() + .map(|message| message.text.clone()), + ) + .collect() + } +} diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs new file mode 100644 index 000000000000..ca15f1cdf746 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -0,0 +1,380 @@ +//! Input queue restore and thread-input snapshot behavior for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { + self.suppress_initial_user_message_submit = suppressed; + } + + pub(crate) fn submit_initial_user_message_if_pending(&mut self) { + if let Some(user_message) = self.initial_user_message.take() { + self.submit_user_message(user_message); + } + } + + pub(super) fn pop_next_queued_user_message( + &mut self, + ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { + if self.input_queue.rejected_steers_queue.is_empty() { + self.input_queue + .queued_user_messages + .pop_front() + .map(|user_message| { + let history_record = self + .input_queue + .queued_user_message_history_records + .pop_front() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + (user_message, history_record) + }) + } else { + let rejected_messages = self + .input_queue + .rejected_steers_queue + .drain(..) + .collect::>(); + let mut history_records = self + .input_queue + .rejected_steer_history_records + .drain(..) + .collect::>(); + history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let (message, history_record) = merge_user_messages_with_history_record( + rejected_messages + .into_iter() + .zip(history_records) + .collect::>(), + ); + Some((QueuedUserMessage::from(message), history_record)) + } + } + + pub(super) fn pop_latest_queued_user_message(&mut self) -> Option { + if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { + let history_record = self + .input_queue + .queued_user_message_history_records + .pop_back() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + Some(user_message_for_restore( + user_message.into_user_message(), + &history_record, + )) + } else { + let user_message = self.input_queue.rejected_steers_queue.pop_back()?; + let history_record = self + .input_queue + .rejected_steer_history_records + .pop_back() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + Some(user_message_for_restore(user_message, &history_record)) + } + } + + pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { + let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else { + tracing::warn!( + "received active-turn-not-steerable error without a matching pending steer" + ); + return false; + }; + self.input_queue + .rejected_steers_queue + .push_back(pending_steer.user_message); + self.input_queue + .rejected_steer_history_records + .push_back(pending_steer.history_record); + self.refresh_pending_input_preview(); + true + } + + /// Handle a turn aborted due to user interrupt (Esc), budget exhaustion, + /// or review completion. + /// When there are queued user messages, restore them into the composer + /// separated by newlines rather than auto-submitting the next one. + pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { + // Finalize, log a gentle prompt, and clear running state. + self.finalize_turn(); + let send_pending_steers_immediately = + self.input_queue.submit_pending_steers_after_interrupt; + self.input_queue.submit_pending_steers_after_interrupt = false; + if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { + if send_pending_steers_immediately { + self.add_to_history(history_cell::new_info_event( + "Model interrupted to submit steer instructions.".to_owned(), + /*hint*/ None, + )); + } else { + self.add_to_history(history_cell::new_error_event( + self.interrupted_turn_message(reason), + )); + } + } + + // The server has already discarded pending input by the time the + // interrupted turn reaches the UI, so any unacknowledged steers still + // tracked here must be restored locally instead of waiting for a later commit. + if send_pending_steers_immediately { + let pending_steers = self + .input_queue + .pending_steers + .drain(..) + .map(|pending| (pending.user_message, pending.history_record)) + .collect::>(); + if !pending_steers.is_empty() { + let (user_message, history_record) = + merge_user_messages_with_history_record(pending_steers); + self.submit_user_message_with_history_record(user_message, history_record); + } else if let Some(combined) = self.drain_pending_messages_for_restore() { + self.restore_user_message_to_composer(combined); + } + } else if let Some(combined) = self.drain_pending_messages_for_restore() { + self.restore_user_message_to_composer(combined); + } + self.refresh_pending_input_preview(); + + self.request_redraw(); + } + + /// Merge pending steers, queued drafts, and the current composer state into a single message. + /// + /// Each pending message numbers attachments from `[Image #1]` relative to its own remote + /// images. When we concatenate multiple messages after interrupt, we must renumber local-image + /// placeholders in a stable order and rebase text element byte ranges so the restored composer + /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to + /// restore. + fn drain_pending_messages_for_restore(&mut self) -> Option { + if self.input_queue.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + mention_bindings: self.bottom_pane.composer_mention_bindings(), + }; + + let rejected_messages = self + .input_queue + .rejected_steers_queue + .drain(..) + .collect::>(); + let mut rejected_history_records = self + .input_queue + .rejected_steer_history_records + .drain(..) + .collect::>(); + rejected_history_records.resize( + rejected_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let mut to_merge: Vec = rejected_messages + .into_iter() + .zip(rejected_history_records.iter()) + .map(|(message, history_record)| user_message_for_restore(message, history_record)) + .collect(); + to_merge.extend( + self.input_queue + .pending_steers + .drain(..) + .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), + ); + let queued_messages = self + .input_queue + .queued_user_messages + .drain(..) + .collect::>(); + let mut queued_history_records = self + .input_queue + .queued_user_message_history_records + .drain(..) + .collect::>(); + queued_history_records.resize( + queued_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + to_merge.extend( + queued_messages + .into_iter() + .zip(queued_history_records.iter()) + .map(|(message, history_record)| { + user_message_for_restore(message.into_user_message(), history_record) + }), + ); + if !existing_message.text.is_empty() + || !existing_message.local_images.is_empty() + || !existing_message.remote_image_urls.is_empty() + { + to_merge.push(existing_message); + } + + Some(merge_user_messages(to_merge)) + } + + pub(crate) fn restore_user_message_to_composer(&mut self, user_message: UserMessage) { + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = user_message; + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.set_remote_image_urls(remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + } + + pub(crate) fn capture_thread_input_state(&self) -> Option { + let composer = ThreadComposerState { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + mention_bindings: self.bottom_pane.composer_mention_bindings(), + pending_pastes: self.bottom_pane.composer_pending_pastes(), + }; + Some(ThreadInputState { + composer: composer.has_content().then_some(composer), + pending_steers: self + .input_queue + .pending_steers + .iter() + .map(|pending| pending.user_message.clone()) + .collect(), + pending_steer_history_records: self + .input_queue + .pending_steers + .iter() + .map(|pending| pending.history_record.clone()) + .collect(), + pending_steer_compare_keys: self + .input_queue + .pending_steers + .iter() + .map(|pending| pending.compare_key.clone()) + .collect(), + rejected_steers_queue: self.input_queue.rejected_steers_queue.clone(), + rejected_steer_history_records: self.input_queue.rejected_steer_history_records.clone(), + queued_user_messages: self.input_queue.queued_user_messages.clone(), + queued_user_message_history_records: self + .input_queue + .queued_user_message_history_records + .clone(), + user_turn_pending_start: self.input_queue.user_turn_pending_start, + current_collaboration_mode: self.current_collaboration_mode.clone(), + active_collaboration_mask: self.active_collaboration_mask.clone(), + task_running: self.bottom_pane.is_task_running(), + agent_turn_running: self.turn_lifecycle.agent_turn_running, + }) + } + + pub(crate) fn restore_thread_input_state(&mut self, input_state: Option) { + let restored_task_running = input_state.as_ref().is_some_and(|state| state.task_running); + if let Some(input_state) = input_state { + self.current_collaboration_mode = input_state.current_collaboration_mode; + self.active_collaboration_mask = input_state.active_collaboration_mask; + self.turn_lifecycle + .restore_running(input_state.agent_turn_running, Instant::now()); + self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; + self.update_collaboration_mode_indicator(); + self.refresh_model_dependent_surfaces(); + if let Some(composer) = input_state.composer { + let local_image_paths = composer + .local_images + .into_iter() + .map(|img| img.path) + .collect(); + self.set_remote_image_urls(composer.remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + composer.text, + composer.text_elements, + local_image_paths, + composer.mention_bindings, + ); + self.bottom_pane + .set_composer_pending_pastes(composer.pending_pastes); + } else { + self.set_remote_image_urls(Vec::new()); + self.bottom_pane.set_composer_text_with_mention_bindings( + String::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + self.bottom_pane.set_composer_pending_pastes(Vec::new()); + } + let mut pending_steer_history_records = input_state.pending_steer_history_records; + pending_steer_history_records.resize( + input_state.pending_steers.len(), + UserMessageHistoryRecord::UserMessageText, + ); + let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; + self.input_queue.pending_steers = input_state + .pending_steers + .into_iter() + .zip(pending_steer_history_records) + .map(|(user_message, history_record)| PendingSteer { + compare_key: pending_steer_compare_keys.pop_front().unwrap_or_else(|| { + PendingSteerCompareKey { + message: user_message.text.clone(), + image_count: user_message.local_images.len() + + user_message.remote_image_urls.len(), + } + }), + history_record, + user_message, + }) + .collect(); + self.input_queue.rejected_steers_queue = input_state.rejected_steers_queue; + self.input_queue.rejected_steer_history_records = + input_state.rejected_steer_history_records; + self.input_queue.rejected_steer_history_records.resize( + self.input_queue.rejected_steers_queue.len(), + UserMessageHistoryRecord::UserMessageText, + ); + self.input_queue.queued_user_messages = input_state.queued_user_messages; + self.input_queue.queued_user_message_history_records = + input_state.queued_user_message_history_records; + self.input_queue.queued_user_message_history_records.resize( + self.input_queue.queued_user_messages.len(), + UserMessageHistoryRecord::UserMessageText, + ); + } else { + self.turn_lifecycle + .restore_running(/*running*/ false, Instant::now()); + self.input_queue.clear(); + self.set_remote_image_urls(Vec::new()); + self.bottom_pane.set_composer_text_with_mention_bindings( + String::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + self.bottom_pane.set_composer_pending_pastes(Vec::new()); + } + self.turn_lifecycle + .restore_running(self.turn_lifecycle.agent_turn_running, Instant::now()); + self.update_task_running_state(); + if restored_task_running && !self.bottom_pane.is_task_running() { + self.bottom_pane.set_task_running(/*running*/ true); + self.refresh_status_surfaces(); + } + self.refresh_pending_input_preview(); + self.request_redraw(); + } + + pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { + self.input_queue.suppress_queue_autosend = suppressed; + } +} diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs new file mode 100644 index 000000000000..9043cb3d9dc0 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -0,0 +1,439 @@ +//! User-message and shell-prompt submission behavior for `ChatWidget`. + +use super::*; + +impl ChatWidget { + pub(super) fn user_message_from_submission( + &mut self, + text: String, + text_elements: Vec, + ) -> UserMessage { + let local_images = self + .bottom_pane + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(), + } + } + + fn submit_shell_command(&mut self, command: &str) -> QueueDrain { + let cmd = command.trim(); + if cmd.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + USER_SHELL_COMMAND_HELP_TITLE.to_string(), + Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), + ), + ))); + QueueDrain::Continue + } else { + self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); + QueueDrain::Stop + } + } + + fn submit_shell_command_with_history( + &mut self, + command: &str, + history_text: &str, + ) -> QueueDrain { + let drain = self.submit_shell_command(command); + if drain == QueueDrain::Stop { + self.append_message_history_entry(history_text.to_string()); + } + drain + } + + pub(super) fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { + match user_message.text.strip_prefix('!') { + Some(command) => { + let history_text = user_message.text.clone(); + self.submit_shell_command_with_history(command, &history_text) + } + None => { + self.submit_user_message(user_message); + QueueDrain::Stop + } + } + } + + pub(super) fn submit_user_message(&mut self, user_message: UserMessage) { + let _accepted = self.submit_user_message_with_history_record( + user_message, + UserMessageHistoryRecord::UserMessageText, + ); + } + + pub(super) fn submit_user_message_with_history_record( + &mut self, + user_message: UserMessage, + history_record: UserMessageHistoryRecord, + ) -> bool { + self.submit_user_message_with_history_and_shell_escape_policy( + user_message, + history_record, + ShellEscapePolicy::Allow, + ) + .0 + } + + pub(super) fn submit_user_message_with_shell_escape_policy( + &mut self, + user_message: UserMessage, + shell_escape_policy: ShellEscapePolicy, + ) -> Option { + self.submit_user_message_with_history_and_shell_escape_policy( + user_message, + UserMessageHistoryRecord::UserMessageText, + shell_escape_policy, + ) + .1 + } + + fn submit_user_message_with_history_and_shell_escape_policy( + &mut self, + user_message: UserMessage, + history_record: UserMessageHistoryRecord, + shell_escape_policy: ShellEscapePolicy, + ) -> (bool, Option) { + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); + self.input_queue + .queued_user_messages + .push_front(QueuedUserMessage::from(user_message)); + self.input_queue + .queued_user_message_history_records + .push_front(history_record); + self.refresh_pending_input_preview(); + return (true, None); + } + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return (false, None); + } + if (!user_message.local_images.is_empty() || !user_message.remote_image_urls.is_empty()) + && !self.current_model_supports_images() + { + let UserMessage { + text, + text_elements, + local_images, + mention_bindings, + remote_image_urls, + } = user_message_for_restore(user_message, &history_record); + self.restore_blocked_image_submission( + text, + text_elements, + local_images, + mention_bindings, + remote_image_urls, + ); + return (false, None); + } + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = user_message; + + let render_in_history = !self.turn_lifecycle.agent_turn_running; + let mut items: Vec = Vec::new(); + + // Special-case: "!cmd" executes a local shell command instead of sending to the model. + if shell_escape_policy == ShellEscapePolicy::Allow + && let Some(stripped) = text.strip_prefix('!') + { + let app_command = match self.submit_shell_command_with_history(stripped, &text) { + QueueDrain::Continue => None, + QueueDrain::Stop => Some(AppCommand::run_user_shell_command( + stripped.trim().to_string(), + )), + }; + return (app_command.is_some(), app_command); + } + + for image_url in &remote_image_urls { + items.push(UserInput::Image { + url: image_url.clone(), + }); + } + + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); + } + + if !text.is_empty() { + items.push(UserInput::Text { + text: text.clone(), + text_elements: app_server_text_elements(&text_elements), + }); + } + + let mentions = collect_tool_mentions(&text, &HashMap::new()); + let bound_names: HashSet = mention_bindings + .iter() + .map(|binding| binding.mention.clone()) + .collect(); + let mut skill_names_lower: HashSet = HashSet::new(); + let mut selected_skill_paths: HashSet = HashSet::new(); + let mut selected_plugin_ids: HashSet = HashSet::new(); + + if let Some(skills) = self.bottom_pane.skills() { + skill_names_lower = skills + .iter() + .map(|skill| skill.name.to_ascii_lowercase()) + .collect(); + + for binding in &mention_bindings { + let path = binding + .path + .strip_prefix("skill://") + .unwrap_or(binding.path.as_str()); + let path = Path::new(path); + if let Some(skill) = skills + .iter() + .find(|skill| skill.path_to_skills_md.as_path() == path) + && selected_skill_paths.insert(skill.path_to_skills_md.clone()) + { + items.push(UserInput::Skill { + name: skill.name.clone(), + path: skill.path_to_skills_md.to_path_buf(), + }); + } + } + + let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); + for skill in skill_mentions { + if bound_names.contains(skill.name.as_str()) + || !selected_skill_paths.insert(skill.path_to_skills_md.clone()) + { + continue; + } + items.push(UserInput::Skill { + name: skill.name.clone(), + path: skill.path_to_skills_md.to_path_buf(), + }); + } + } + + if let Some(plugins) = self.plugins_for_mentions() { + for binding in &mention_bindings { + let Some(plugin_config_name) = binding + .path + .strip_prefix("plugin://") + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !selected_plugin_ids.insert(plugin_config_name.to_string()) { + continue; + } + if let Some(plugin) = plugins + .iter() + .find(|plugin| plugin.config_name == plugin_config_name) + { + items.push(UserInput::Mention { + name: plugin.display_name.clone(), + path: binding.path.clone(), + }); + } + } + } + + let mut selected_app_ids: HashSet = HashSet::new(); + if let Some(apps) = self.connectors_for_mentions() { + for binding in &mention_bindings { + let Some(app_id) = binding + .path + .strip_prefix("app://") + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !selected_app_ids.insert(app_id.to_string()) { + continue; + } + if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { + items.push(UserInput::Mention { + name: app.name.clone(), + path: binding.path.clone(), + }); + } + } + + let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); + for app in app_mentions { + let slug = codex_connectors::metadata::connector_mention_slug(&app); + if bound_names.contains(&slug) || !selected_app_ids.insert(app.id.clone()) { + continue; + } + let app_id = app.id.as_str(); + items.push(UserInput::Mention { + name: app.name.clone(), + path: format!("app://{app_id}"), + }); + } + } + + let effective_mode = self.effective_collaboration_mode(); + if effective_mode.model().trim().is_empty() { + self.add_error_message( + "Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(), + ); + self.restore_user_message_to_composer(user_message_for_restore( + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + &history_record, + )); + return (false, None); + } + + self.maybe_apply_ide_context(&mut items); + + let collaboration_mode = if self.collaboration_modes_enabled() { + self.active_collaboration_mask + .as_ref() + .map(|_| effective_mode.clone()) + } else { + None + }; + let pending_steer = (!render_in_history).then(|| PendingSteer { + user_message: UserMessage { + text: text.clone(), + local_images: local_images.clone(), + remote_image_urls: remote_image_urls.clone(), + text_elements: text_elements.clone(), + mention_bindings: mention_bindings.clone(), + }, + history_record: history_record.clone(), + compare_key: Self::pending_steer_compare_key_from_items(&items), + }); + let personality = self + .config + .personality + .filter(|_| self.config.features.enabled(Feature::Personality)) + .filter(|_| self.current_model_supports_personality()); + let service_tier = match self.config.service_tier.clone() { + Some(service_tier) => Some(Some(service_tier)), + None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), + None => None, + }; + let permission_profile = self.config.permissions.permission_profile(); + let op = AppCommand::user_turn( + items, + self.config.cwd.to_path_buf(), + AskForApproval::from(self.config.permissions.approval_policy.value()), + permission_profile, + effective_mode.model().to_string(), + effective_mode.reasoning_effort(), + /*summary*/ None, + service_tier, + /*final_output_json_schema*/ None, + collaboration_mode, + personality, + ); + + if !self.submit_op(op.clone()) { + return (false, None); + } + if render_in_history { + self.input_queue.user_turn_pending_start = true; + } + + // Persist the submitted text to cross-session message history. Mentions are encoded into + // placeholder syntax so recall can reconstruct the mention bindings in a future session. + let encoded_mentions = mention_bindings + .iter() + .map(|binding| LinkedMention { + mention: binding.mention.clone(), + path: binding.path.clone(), + }) + .collect::>(); + let history_text = match &history_record { + UserMessageHistoryRecord::UserMessageText if !text.is_empty() => { + Some(encode_history_mentions(&text, &encoded_mentions)) + } + UserMessageHistoryRecord::Override(history) if !history.text.is_empty() => { + Some(encode_history_mentions(&history.text, &encoded_mentions)) + } + UserMessageHistoryRecord::UserMessageText | UserMessageHistoryRecord::Override(_) => { + None + } + }; + if let Some(history_text) = history_text { + self.append_message_history_entry(history_text); + } + + if let Some(pending_steer) = pending_steer { + self.input_queue.pending_steers.push_back(pending_steer); + self.transcript.saw_plan_item_this_turn = false; + self.refresh_pending_input_preview(); + } + + // Show replayable user content in conversation history. + let display_user_message = render_in_history.then(|| { + user_message_display_for_history( + UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + }, + &history_record, + ) + }); + if let Some(display) = display_user_message { + self.on_user_message_display(display); + } + + self.transcript.needs_final_message_separator = false; + (true, Some(op)) + } + + /// Restore the blocked submission draft without losing mention resolution state. + /// + /// The blocked-image path intentionally keeps the draft in the composer so + /// users can remove attachments and retry. We must restore + /// mention bindings alongside visible text; restoring only `$name` tokens + /// makes the draft look correct while degrading mention resolution to + /// name-only heuristics on retry. + pub(super) fn restore_blocked_image_submission( + &mut self, + text: String, + text_elements: Vec, + local_images: Vec, + mention_bindings: Vec, + remote_image_urls: Vec, + ) { + // Preserve the user's composed payload so they can retry after changing models. + let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect(); + self.set_remote_image_urls(remote_image_urls); + self.bottom_pane.set_composer_text_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + self.add_to_history(history_cell::new_warning_event( + self.image_inputs_not_supported_message(), + )); + self.request_redraw(); + } +}