Refactor chatwidget input flow into modules (#22407)

## Why

`chatwidget.rs` is still carrying too many unrelated responsibilities in
one file. #22269 started a five-phase effort to move coherent behavior
domains into focused modules while keeping `chatwidget.rs` as the
composition layer.

This PR is phase 2 of that plan. It extracts the input and submission
flow as a mechanical move before the later protocol, popup/status, and
constructor/orchestration phases.

## What Changed

- Added `codex-rs/tui/src/chatwidget/input_flow.rs` for composer input
results, queued user-message draining, pending-input previews, and
mode-specific submission entry points.
- Added `codex-rs/tui/src/chatwidget/input_submission.rs` for
user-message construction/submission, shell prompt submission,
structured mention resolution, and blocked image draft restoration.
- Added `codex-rs/tui/src/chatwidget/input_restore.rs` for
initial-message submission, pending steer restoration after interrupts,
and thread input snapshot/restore behavior.
- Registered the new modules and removed the moved `ChatWidget` impl
methods from `codex-rs/tui/src/chatwidget.rs`.

## Follow-On Refactor Phases

The five-phase plan from #22269 is:

- Phase 1: mechanical helper and state moves. Completed in #22269.
- Phase 2: extract input and submission flow, including queued user
messages, shell prompt submission, pending steer restoration, and thread
input snapshot/restore behavior. This PR.
- Phase 3: extract protocol, replay, streaming, and tool lifecycle
handling, while preserving active-cell grouping, transcript
invalidation, interrupt deferral, and final-message separator behavior.
- Phase 4: extract settings, popups, and status surfaces, including
model/reasoning/collaboration/personality popups, permission prompts,
rate-limit UI, and connectors helpers.
- Phase 5: clean up the remaining constructor and orchestration code
once the larger behavior domains have moved out, leaving `chatwidget.rs`
as the composition layer.
This commit is contained in:
Eric Traut
2026-05-12 21:17:35 -07:00
committed by GitHub
Unverified
parent ad572709ab
commit 2630a6ca35
4 changed files with 1030 additions and 1005 deletions
File diff suppressed because it is too large Load Diff
+206
View File
@@ -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<String> {
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()
}
}
@@ -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::<Vec<_>>();
let mut history_records = self
.input_queue
.rejected_steer_history_records
.drain(..)
.collect::<Vec<_>>();
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::<Vec<_>>(),
);
Some((QueuedUserMessage::from(message), history_record))
}
}
pub(super) fn pop_latest_queued_user_message(&mut self) -> Option<UserMessage> {
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::<Vec<_>>();
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<UserMessage> {
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::<Vec<_>>();
let mut rejected_history_records = self
.input_queue
.rejected_steer_history_records
.drain(..)
.collect::<Vec<_>>();
rejected_history_records.resize(
rejected_messages.len(),
UserMessageHistoryRecord::UserMessageText,
);
let mut to_merge: Vec<UserMessage> = 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::<Vec<_>>();
let mut queued_history_records = self
.input_queue
.queued_user_message_history_records
.drain(..)
.collect::<Vec<_>>();
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<ThreadInputState> {
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<ThreadInputState>) {
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;
}
}
@@ -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<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(),
});
}
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<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.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::<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();
}
// 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();
}
}