//! 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<TextElement>, ) -> 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<AppCommand> { 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<AppCommand>) { 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<UserInput> = 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(), detail: None, }); } for image in &local_images { items.push(UserInput::LocalImage { path: image.path.clone(), detail: None, }); } 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<String> = mention_bindings .iter() .map(|binding| binding.mention.clone()) .collect(); let mut skill_names_lower: HashSet<String> = HashSet::new(); let mut selected_skill_paths: HashSet<AbsolutePathBuf> = HashSet::new(); let mut selected_plugin_ids: HashSet<String> = 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<String> = 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.contains(app_id) { continue; } if let Some(app) = apps .iter() .find(|app| app.id == app_id && is_app_mentionable(app)) { selected_app_ids.insert(app_id.to_string()); 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 = self.service_tier_update_for_core(); let active_permission_profile = self.config.permissions.active_permission_profile(); let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), AskForApproval::from(self.config.permissions.approval_policy.value()), active_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 { sigil: binding.sigil, mention: binding.mention.clone(), path: binding.path.clone(), }) .collect::<Vec<_>>(); 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(); } if render_in_history { self.record_cancel_edit_candidate(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(), }); } // 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<TextElement>, local_images: Vec<LocalImageAttachment>, mention_bindings: Vec<MentionBinding>, remote_image_urls: Vec<String>, ) { // 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(); }}