mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
ad572709ab
commit
2630a6ca35
+5
-1005
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user