Codex Handbook
tui/src/chatwidget/input_submission.rs 452 lines
//! 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();    }}