返回 CodeWhale
dispatch.rs
根目录 / crates / tui / src / tui / ui / dispatch.rs
1 //! Getting a composed user message into a turn: dispatch, steering, and the
2 //! offline/queued message paths.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7 use crate::core::ops::TurnSpec;
8 use codewhale_models::Role;
9
10 pub(crate) fn dispatch_hotbar_slot(
11 app: &mut App,
12 config: &Config,
13 slot: u8,
14 ) -> Result<Option<HotbarDispatch>> {
15 let known_action_ids = app
16 .hotbar_actions
17 .iter()
18 .map(|action| action.id())
19 .collect::<Vec<_>>();
20 let bindings = config.resolve_hotbar_bindings(&known_action_ids).bindings;
21 let Some(action_id) = bindings
22 .iter()
23 .find(|binding| binding.slot == slot)
24 .map(|binding| binding.action.clone())
25 else {
26 return Ok(None);
27 };
28
29 let Some(action) = app.hotbar_actions.get(&action_id) else {
30 app.status_message = Some(format!(
31 "Hotbar slot {slot} action is not available: {action_id}"
32 ));
33 app.needs_redraw = true;
34 return Ok(Some(HotbarDispatch::Handled));
35 };
36
37 if let Some(reason) = action.disabled_reason(app) {
38 app.status_message = Some(format!(
39 "Hotbar slot {slot} action is not available: {reason}"
40 ));
41 app.needs_redraw = true;
42 return Ok(Some(HotbarDispatch::Handled));
43 }
44
45 action.dispatch(app).map(Some)
46 }
47
48 pub(crate) fn queued_ui_to_session(msg: &QueuedMessage) -> QueuedSessionMessage {
49 QueuedSessionMessage {
50 display: msg.display.clone(),
51 skill_instruction: msg.skill_instruction.clone(),
52 skill_provenance: msg.skill_provenance.clone(),
53 }
54 }
55
56 pub(crate) fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage {
57 QueuedMessage {
58 display: msg.display,
59 skill_instruction: msg.skill_instruction,
60 skill_provenance: msg.skill_provenance,
61 // Persistence does not carry this flag; restore may re-echo or rely on
62 // pending preview. Live Queue path sets true after painting.
63 history_echoed: false,
64 }
65 }
66
67 /// Echo a freshly submitted turn into the transcript before it waits on the
68 /// queue / offline bucket. Ops contract: every submit paints `HistoryCell::User`
69 /// before the model runs (queued included).
70 pub(crate) fn echo_queued_user_turn(app: &mut App, message: &mut QueuedMessage) {
71 if message.history_echoed {
72 return;
73 }
74 app.add_message(HistoryCell::User {
75 content: message.display.clone(),
76 });
77 message.history_echoed = true;
78 app.needs_redraw = true;
79 app.scroll_to_bottom();
80 }
81
82 /// Paint the transcript cell for a submitted user turn, reusing the cell that
83 /// queue-time echo already painted when there is one. Exactly one
84 /// `HistoryCell::User` must represent a message across queue -> steer ->
85 /// dispatch; returns that cell's index.
86 pub(crate) fn paint_user_turn_cell(
87 app: &mut App,
88 message: &QueuedMessage,
89 content: String,
90 ) -> usize {
91 if message.history_echoed
92 && let Some(idx) = app
93 .history
94 .iter()
95 .enumerate()
96 .rev()
97 .find_map(|(idx, cell)| match cell {
98 HistoryCell::User { content } if content == &message.display => Some(idx),
99 _ => None,
100 })
101 {
102 app.history[idx] = HistoryCell::User { content };
103 app.needs_redraw = true;
104 return idx;
105 }
106 app.add_message(HistoryCell::User { content });
107 app.history.len().saturating_sub(1)
108 }
109
110 pub(crate) fn enqueue_offline_message(app: &mut App, message: QueuedMessage) {
111 app.queue_message(message);
112 persist_offline_queue_state(app);
113 }
114
115 pub(crate) fn push_assistant_message(
116 app: &mut App,
117 text: String,
118 thinking: Option<String>,
119 tool_uses: PendingToolUses,
120 ) {
121 let mut blocks = Vec::new();
122 if let Some(thinking) = thinking {
123 blocks.push(ContentBlock::Thinking {
124 thinking,
125 signature: None,
126 state: None,
127 });
128 }
129 if !text.is_empty() {
130 blocks.push(ContentBlock::Text {
131 text,
132 cache_control: None,
133 });
134 }
135 for (id, name, input) in tool_uses {
136 blocks.push(ContentBlock::ToolUse {
137 id,
138 name,
139 input,
140 caller: None,
141 thought_signature: None,
142 });
143 }
144
145 let has_sendable_content = blocks.iter().any(|block| {
146 matches!(
147 block,
148 ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
149 )
150 });
151 if has_sendable_content {
152 app.push_api_message(Message {
153 role: Role::Assistant,
154 content: blocks,
155 });
156 }
157 }
158
159 pub(crate) fn replace_matching_assistant_text(
160 app: &mut App,
161 original_text: &str,
162 translated_text: String,
163 ) -> bool {
164 for message in app.api_messages_mut().iter_mut().rev() {
165 if message.role != "assistant"
166 && message.role != codewhale_models::INTERRUPTED_ASSISTANT_ROLE
167 {
168 continue;
169 }
170 for block in &mut message.content {
171 if let ContentBlock::Text { text, .. } = block
172 && text == original_text
173 {
174 *text = translated_text;
175 return true;
176 }
177 }
178 }
179 false
180 }
181
182 pub(crate) fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
183 let skill_instruction = app.active_skill.take();
184 let skill_provenance = app.active_skill_provenance.take();
185 QueuedMessage::new(input, skill_instruction).with_skill_provenance(skill_provenance)
186 }
187
188 pub(crate) fn allowed_tools_for_message(
189 configured: Option<Vec<String>>,
190 message: &QueuedMessage,
191 ) -> Option<Vec<String>> {
192 if message.is_workflow_draft() {
193 // `/workflow <objective>` is review-first. The model may draft and ask
194 // for confirmation, but the host makes execution impossible in the
195 // same turn even if the provider ignores that instruction.
196 Some(Vec::new())
197 } else {
198 configured
199 }
200 }
201
202 pub(crate) async fn submit_initial_input_if_ready(
203 app: &mut App,
204 config: &Config,
205 engine_handle: &EngineHandle,
206 ) -> Result<()> {
207 if !app.auto_submit_initial_input {
208 return Ok(());
209 }
210
211 if app.onboarding != OnboardingState::None || app.redaction_gate {
212 if app.status_message.is_none() && !app.input.trim().is_empty() {
213 app.status_message = Some(INITIAL_PROMPT_DEFERRED_STATUS.to_string());
214 }
215 return Ok(());
216 }
217
218 app.auto_submit_initial_input = false;
219 if let Some(input) = app.submit_input() {
220 if app.status_message.as_deref() == Some(INITIAL_PROMPT_DEFERRED_STATUS) {
221 app.status_message = None;
222 }
223 let queued = build_queued_message(app, input);
224 dispatch_user_message_with_recovery(
225 app,
226 config,
227 engine_handle,
228 queued,
229 DispatchRecovery::Initial,
230 )
231 .await?;
232 }
233 Ok(())
234 }
235
236 pub(crate) fn message_from_submitted_input(
237 app: &mut App,
238 input: String,
239 ) -> (QueuedMessage, DispatchRecovery) {
240 if let Some(mut draft) = app.queued_draft.take() {
241 draft.display = input;
242 (draft, DispatchRecovery::Draft)
243 } else {
244 (
245 build_queued_message(app, input),
246 DispatchRecovery::Immediate,
247 )
248 }
249 }
250
251 pub(crate) fn take_next_queued_message(app: &mut App) -> Option<(QueuedMessage, DispatchRecovery)> {
252 if app.input.is_empty() {
253 return app.remove_queued_message(0).map(|message| {
254 (
255 message,
256 DispatchRecovery::Queued {
257 restore_index: Some(0),
258 },
259 )
260 });
261 }
262 None
263 }
264
265 pub(crate) async fn send_next_queued_message_now(
266 app: &mut App,
267 config: &Config,
268 engine_handle: &EngineHandle,
269 ) -> Result<bool> {
270 let Some((message, recovery)) = take_next_queued_message(app) else {
271 return Ok(false);
272 };
273 send_taken_queued_message_now(app, config, engine_handle, message, recovery).await?;
274 Ok(true)
275 }
276
277 pub(crate) async fn send_queued_message_at_index_now(
278 app: &mut App,
279 config: &Config,
280 engine_handle: &EngineHandle,
281 index: usize,
282 ) -> Result<bool> {
283 let Some(message) = app.remove_queued_message(index) else {
284 app.status_message = Some("Queued message not found".to_string());
285 return Ok(true);
286 };
287 send_taken_queued_message_now(
288 app,
289 config,
290 engine_handle,
291 message,
292 DispatchRecovery::Queued {
293 restore_index: Some(index),
294 },
295 )
296 .await?;
297 Ok(true)
298 }
299
300 pub(crate) async fn send_taken_queued_message_now(
301 app: &mut App,
302 config: &Config,
303 engine_handle: &EngineHandle,
304 message: QueuedMessage,
305 recovery: DispatchRecovery,
306 ) -> Result<()> {
307 if app.offline_mode {
308 restore_queued_or_draft_message(app, recovery, message);
309 app.status_message = Some(
310 app.tr(MessageId::ToastOfflineQueuedCount)
311 .replace("{count}", &app.queued_message_count().to_string()),
312 );
313 return Ok(());
314 }
315
316 if app.dispatch_in_flight {
317 // A spawned dispatch is still resolving route/sending its op (#4605):
318 // there is no turn to steer into yet. Re-queue; the completion/turn
319 // lifecycle will drive the next drain.
320 restore_queued_or_draft_message(app, recovery, message);
321 app.status_message = Some(queued_follow_up_toast(app));
322 return Ok(());
323 }
324 if app.is_loading {
325 match steer_user_message(app, config, engine_handle, message.clone()).await {
326 Ok(true) => app.push_status_toast(
327 app.tr(MessageId::ToastSentIntoTurn).into_owned(),
328 StatusToastLevel::Info,
329 Some(1_500),
330 ),
331 Ok(false) => {
332 restore_queued_or_draft_message(app, recovery, message);
333 app.push_status_toast(
334 app.tr(MessageId::ToastHookBlockedFollowUp).into_owned(),
335 StatusToastLevel::Warning,
336 Some(4_000),
337 );
338 }
339 Err(err) => {
340 restore_queued_or_draft_message(app, recovery, message);
341 app.status_message = Some(format!(
342 "{} ({err})",
343 app.tr(MessageId::ToastCouldNotSendIntoTurn)
344 ));
345 }
346 }
347 } else if let Err(_err) =
348 dispatch_user_message_with_recovery(app, config, engine_handle, message, recovery).await
349 {
350 // The completion closure re-queued the message and set the status.
351 } else {
352 app.status_message = Some(app.tr(MessageId::ToastSentIntoTurn).into_owned());
353 }
354 Ok(())
355 }
356
357 pub(crate) fn queued_message_content_for_app(
358 app: &App,
359 message: &QueuedMessage,
360 cwd: Option<PathBuf>,
361 git_cache: &mut crate::tui::git_mention::GitMentionCache,
362 ) -> Result<String> {
363 if let Some(authority) = message.skill_provenance.as_ref() {
364 if authority.workspace != app.workspace {
365 anyhow::bail!("Queued plugin skill belongs to a different workspace and was denied");
366 }
367 crate::plugins::registry::verify_plugin_component_authority(
368 authority,
369 crate::plugins::activation::PluginActivationCapability::Skills,
370 )
371 .map_err(anyhow::Error::msg)?;
372 }
373 // Pass the process CWD explicitly so the resolver's two-pass logic can
374 // honor the user's launch directory when it differs from `--workspace`
375 // (issue #101 — file mentions silently routing to the wrong root).
376 // The completion index is the composer's already-built fuzzy scan: a
377 // bounded fallback for exact misses, with no submit-time tree walk (#4365).
378 let completion_index = app.composer.mention_discovery.fuzzy_candidates(
379 &app.workspace,
380 &app.composer.mention_cwd,
381 app.mention_walk_depth,
382 app.workspace_follow_symlinks,
383 );
384 // Stabilize macOS screencapture temp references before anything else sees
385 // the text: macOS deletes those Temporary Items dirs minutes after capture.
386 let stabilization_dir = crate::tui::file_mention::screenshot_stabilization_dir(&app.workspace);
387 let display = crate::tui::file_mention::stabilize_screenshot_references(
388 &message.display,
389 &stabilization_dir,
390 );
391 let user_request = crate::tui::file_mention::user_request_with_file_mentions_cached(
392 &display,
393 &app.workspace,
394 cwd,
395 git_cache,
396 completion_index,
397 );
398 if let Some(skill_instruction) = message.skill_instruction.as_ref() {
399 Ok(format!(
400 "{skill_instruction}\n\n---\n\nUser request: {user_request}"
401 ))
402 } else {
403 Ok(user_request)
404 }
405 }
406
407 pub(crate) fn dispatch_completion_permit(
408 app: &App,
409 ) -> std::result::Result<
410 tokio::sync::mpsc::OwnedPermit<crate::tui::app::DispatchApplyFn>,
411 &'static str,
412 > {
413 let sender = app
414 .dispatch_completion_tx
415 .clone()
416 .ok_or("dispatch completion mailbox is unavailable")?;
417 sender.try_reserve_owned().map_err(|error| match error {
418 tokio::sync::mpsc::error::TrySendError::Full(_) => "dispatch completion mailbox is full",
419 tokio::sync::mpsc::error::TrySendError::Closed(_) => {
420 "dispatch completion mailbox is closed"
421 }
422 })
423 }
424
425 #[cfg(test)]
426 pub(crate) async fn dispatch_user_message(
427 app: &mut App,
428 config: &Config,
429 engine_handle: &EngineHandle,
430 message: QueuedMessage,
431 ) -> Result<()> {
432 dispatch_user_message_with_recovery(
433 app,
434 config,
435 engine_handle,
436 message,
437 DispatchRecovery::Immediate,
438 )
439 .await
440 }
441
442 pub(crate) async fn dispatch_user_message_with_recovery(
443 app: &mut App,
444 config: &Config,
445 engine_handle: &EngineHandle,
446 mut message: QueuedMessage,
447 recovery: DispatchRecovery,
448 ) -> Result<()> {
449 if app.redaction_gate {
450 recover_unstarted_external_message(app, message, recovery, INITIAL_PROMPT_DEFERRED_STATUS);
451 return Ok(());
452 }
453 let stop_words = config.stop_words();
454 if is_stop_word(&message.display, &stop_words).is_some() {
455 engine_handle.cancel();
456 app.stopped_turn = true;
457 app.status_message = Some("Turn stopped. Tool calls blocked for this turn.".to_string());
458 return Ok(());
459 }
460 app.stopped_turn = false;
461
462 // #1364: run mutable `message_submit` hooks before dispatch. Hooks see the
463 // user's display text and may replace or block it before file mentions,
464 // skill wrapping, history, and model input are resolved.
465 // Fast-path skip when no hooks configured.
466 if app
467 .hooks
468 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
469 {
470 let context = app.base_hook_context().with_message(&message.display);
471 let strict_gates = app
472 .hooks
473 .matched_strict_gate_labels(crate::hooks::HookEvent::MessageSubmit, &context);
474 let hooks = app.hooks.clone();
475 let original_text = message.display.clone();
476
477 if app.dispatch_completion_tx.is_some() {
478 // The foreground transform is a gate, but its child wait belongs
479 // on the blocking pool, never on the terminal event loop. Result
480 // delivery reserves bounded mailbox capacity before any work or
481 // state mutation, so the recovery closure cannot be dropped.
482 let completion_permit = match dispatch_completion_permit(app) {
483 Ok(permit) => permit,
484 Err(error) => {
485 recover_unstarted_external_message(app, message, recovery, error);
486 return Err(anyhow::Error::msg(error));
487 }
488 };
489 app.dispatch_in_flight = true;
490 tokio::spawn(async move {
491 let outcome = match tokio::task::spawn_blocking(move || {
492 hooks.execute_message_submit_transform_for_dispatch(&context, &original_text)
493 })
494 .await
495 {
496 Ok(outcome) => outcome,
497 Err(error) => {
498 tracing::error!(target: "hooks", %error, "message_submit executor task was lost");
499 lost_message_submit_outcome(&strict_gates)
500 }
501 };
502 let apply: crate::tui::app::DispatchApplyFn = Box::new(
503 move |app: &mut App,
504 engine_handle: &EngineHandle,
505 config: &Config|
506 -> anyhow::Result<()> {
507 if !apply_message_submit_outcome(app, &mut message, outcome) {
508 app.dispatch_in_flight = false;
509 restore_message_submit_denial(app, message, recovery);
510 return Ok(());
511 }
512 let _ = start_user_dispatch(app, config, engine_handle, message, recovery);
513 Ok(())
514 },
515 );
516 completion_permit.send(apply);
517 });
518 return Ok(());
519 }
520
521 // Unit tests intentionally omit the event-loop completion channel.
522 // Keep those synchronous from the test's perspective while still
523 // running the blocking child wait off the async runtime worker.
524 let outcome = match tokio::task::spawn_blocking(move || {
525 hooks.execute_message_submit_transform_for_dispatch(&context, &original_text)
526 })
527 .await
528 {
529 Ok(outcome) => outcome,
530 Err(error) => {
531 tracing::error!(target: "hooks", %error, "message_submit executor task was lost");
532 lost_message_submit_outcome(&strict_gates)
533 }
534 };
535 if !apply_message_submit_outcome(app, &mut message, outcome) {
536 restore_message_submit_denial(app, message, recovery);
537 return Ok(());
538 }
539 }
540
541 if app.dispatch_completion_tx.is_some() {
542 return start_user_dispatch(app, config, engine_handle, message, recovery);
543 }
544
545 let prepare = match prepare_user_dispatch(app, config, message.clone()) {
546 Ok(prepare) => prepare,
547 Err(error) => {
548 recover_unstarted_external_message(app, message, recovery, &error.to_string());
549 return Err(error);
550 }
551 };
552 run_prepared_dispatch(app, config, engine_handle, prepare, recovery).await
553 }
554
555 pub(crate) fn lost_message_submit_outcome(
556 strict_gates: &[String],
557 ) -> crate::hooks::MessageSubmitOutcome {
558 if strict_gates.is_empty() {
559 crate::hooks::MessageSubmitOutcome::Unchanged {
560 warning: Some(
561 "message_submit hook executor did not run; submission continued because no strict gate matched"
562 .to_string(),
563 ),
564 }
565 } else {
566 crate::hooks::MessageSubmitOutcome::Blocked {
567 reason: "message_submit hook executor did not run; a strict gate blocked submission"
568 .to_string(),
569 }
570 }
571 }
572
573 pub(crate) fn prepare_user_dispatch(
574 app: &mut App,
575 config: &Config,
576 message: QueuedMessage,
577 ) -> Result<UserDispatchPrepare> {
578 anyhow::ensure!(!app.redaction_gate, "{INITIAL_PROMPT_DEFERRED_STATUS}");
579 let _ = app.maybe_nudge_plugin_for_prompt(&message.display);
580
581 // Plan paused-command changes without touching App or the engine pause
582 // gate. Route selection can await and client preflight can fail; neither
583 // may resume or discard a paused command unless a turn is ready to send.
584 let paused_dispatch = plan_paused_command_message(app, &message.display);
585
586 let cwd = std::env::current_dir().ok();
587 // One cache for this submit: the references pass and the payload pass
588 // otherwise each shell out for `@git`/`@diff`, making git compute a large
589 // working-tree diff twice to attach it once (#4067 review follow-up).
590 let mut git_cache = crate::tui::git_mention::GitMentionCache::default();
591 let completion_index = app.composer.mention_discovery.fuzzy_candidates(
592 &app.workspace,
593 &app.composer.mention_cwd,
594 app.mention_walk_depth,
595 app.workspace_follow_symlinks,
596 );
597 let references = crate::tui::file_mention::context_references_from_input_cached(
598 &message.display,
599 &app.workspace,
600 cwd.clone(),
601 &mut git_cache,
602 completion_index,
603 );
604 let mut content = queued_message_content_for_app(app, &message, cwd, &mut git_cache)?;
605 if let Some(note) = paused_dispatch.note() {
606 content.push_str(note);
607 }
608 let (app_route_identity, route_config) = app_scoped_runtime_config(app, config);
609
610 let should_auto_resolve = auto_router::should_resolve_auto_model_selection(app);
611 let auto_router_context = auto_router::recent_auto_router_context(&app.api_messages);
612
613 // Capture the App state before any optimistic mutation so a failure can
614 // roll back cleanly.
615 let snapshot = UserDispatchSnapshot {
616 is_loading: app.is_loading,
617 suppress_stream_events_until_turn_complete: app.suppress_stream_events_until_turn_complete,
618 runtime_turn_status: app.runtime_turn_status.clone(),
619 receipt_text: app.receipt_text.clone(),
620 receipt_started_at: app.receipt_started_at,
621 tool_evidence: app.tool_evidence.clone(),
622 history_len: app.history.len(),
623 history_revisions_len: app.history_revisions.len(),
624 history_version: app.history_version,
625 api_messages_len: app.api_messages.len(),
626 last_send_at: app.last_send_at,
627 };
628
629 // --- Sync prepare: show the user message and spinner immediately so the
630 // event loop can repaint before network I/O (#4605). The async phase runs
631 // the auto-model route, compaction, and engine send off the render thread.
632 app.is_loading = true;
633 app.runtime_turn_status = None;
634 app.clear_receipt();
635 app.tool_evidence.clear();
636 app.needs_redraw = true;
637
638 let message_index = app.api_messages.len();
639 // Already painted at Queue time — reuse that cell for reference
640 // recording instead of duplicating the bubble.
641 let history_cell = paint_user_turn_cell(app, &message, message.display.clone());
642 app.scroll_to_bottom();
643 // Anchor the tail-flash to the moment the user message appears, not to
644 // the async dispatch completion (which can lag by a route plan). The
645 // failure path restores the pre-send timestamp from the snapshot.
646 app.last_send_at = Some(Instant::now());
647 app.push_api_message(Message {
648 role: Role::User,
649 content: vec![ContentBlock::Text {
650 text: content.clone(),
651 cache_control: None,
652 }],
653 });
654
655 let goal_objective = paused_dispatch.goal_objective(app);
656 let allowed_tools = allowed_tools_for_message(app.active_allowed_tools.clone(), &message);
657
658 Ok(UserDispatchPrepare {
659 message,
660 content,
661 references,
662 paused_dispatch,
663 app_route_identity,
664 route_config,
665 goal_objective,
666 goal_status: app.goal.status,
667 goal_token_budget: app.goal.token_budget,
668 mode: app.mode,
669 api_provider: app.api_provider,
670 app_model: app.model.clone(),
671 auto_model: app.auto_model,
672 reasoning_effort: app.reasoning_effort,
673 allow_shell: app.allow_shell,
674 trust_mode: app.trust_mode,
675 auto_approve: app_auto_approve_enabled(app),
676 approval_mode: app.approval_mode,
677 translation_enabled: app.translation_enabled,
678 allowed_tools,
679 hook_executor: app.runtime_services.hook_executor.clone(),
680 verbosity: app.verbosity.clone(),
681 provenance: UserInputProvenance::ExternalUser,
682 auto_router_context,
683 should_auto_resolve,
684 auto_compact_user_configured: app.auto_compact_user_configured,
685 auto_compact: app.auto_compact,
686 auto_compact_threshold_percent: app.auto_compact_threshold_percent,
687 snapshot,
688 cost_scope: crate::cost_status::scope_token(),
689 message_index,
690 history_cell,
691 })
692 }
693
694 pub(crate) fn start_user_dispatch(
695 app: &mut App,
696 config: &Config,
697 engine_handle: &EngineHandle,
698 message: QueuedMessage,
699 recovery: DispatchRecovery,
700 ) -> Result<()> {
701 let completion_permit = match dispatch_completion_permit(app) {
702 Ok(permit) => permit,
703 Err(error) => {
704 recover_unstarted_external_message(app, message, recovery, error);
705 return Err(anyhow::Error::msg(error));
706 }
707 };
708 let recovery_message = message.clone();
709 let prepare = match prepare_user_dispatch(app, config, message) {
710 Ok(prepare) => prepare,
711 Err(error) => {
712 recover_unstarted_external_message(app, recovery_message, recovery, &error.to_string());
713 return Err(error);
714 }
715 };
716 app.dispatch_in_flight = true;
717 tokio::spawn(spawned_dispatch_execute(
718 prepare,
719 recovery,
720 engine_handle.clone(),
721 completion_permit,
722 ));
723 Ok(())
724 }
725
726 pub(crate) async fn spawned_dispatch_execute(
727 prepare: UserDispatchPrepare,
728 recovery: DispatchRecovery,
729 engine_handle: EngineHandle,
730 completion_permit: tokio::sync::mpsc::OwnedPermit<crate::tui::app::DispatchApplyFn>,
731 ) {
732 let apply = spawned_dispatch_inner(prepare, recovery, engine_handle).await;
733 completion_permit.send(apply);
734 }
735
736 /// Keep classifier receipts owned until the UI admits the operation to Engine.
737 /// Dropping a reserved dispatch (including a closed completion mailbox) must
738 /// settle its already-incurred usage in the original session scope.
739 pub(super) struct UnacceptedDispatchUsage {
740 pub(super) scope: crate::cost_status::CostScopeToken,
741 pub(super) batch: Option<crate::cost_status::RuntimeUsageBatch>,
742 }
743
744 impl Drop for UnacceptedDispatchUsage {
745 fn drop(&mut self) {
746 if let Some(batch) = self.batch.as_ref() {
747 crate::cost_status::report_runtime_usage_batch(self.scope, None, batch);
748 }
749 }
750 }
751
752 pub(crate) async fn spawned_dispatch_inner(
753 prepare: UserDispatchPrepare,
754 recovery: DispatchRecovery,
755 engine_handle: EngineHandle,
756 ) -> crate::tui::app::DispatchApplyFn {
757 // Bound in its own statement: the planner borrows `prepare`, and the error
758 // arm moves it into the failure closure.
759 let plan_result = plan_turn_route(TurnRoutePlanRequest {
760 route_config: &prepare.route_config,
761 app_route_identity: &prepare.app_route_identity,
762 api_provider: prepare.api_provider,
763 app_model: &prepare.app_model,
764 auto_model: prepare.auto_model,
765 reasoning_effort: prepare.reasoning_effort,
766 mode: prepare.mode,
767 content: &prepare.content,
768 auto_router_context: &prepare.auto_router_context,
769 should_auto_resolve: prepare.should_auto_resolve,
770 allow_auto_router_response_cache: true,
771 preflight_required: engine_handle.client_preflight_required(),
772 auto_compact_user_configured: prepare.auto_compact_user_configured,
773 auto_compact: prepare.auto_compact,
774 auto_compact_threshold_percent: prepare.auto_compact_threshold_percent,
775 })
776 .await;
777 let planned = match plan_result {
778 Ok(planned) => planned,
779 Err(err) => return build_dispatch_error_closure(prepare, recovery, err),
780 };
781
782 let PlannedTurnRoute {
783 route: turn_route,
784 compaction: turn_compaction,
785 effective_provider,
786 effective_model,
787 effective_provider_identity,
788 effective_provider_label,
789 selected_reasoning_effort,
790 effective_reasoning_effort,
791 auto_controls_reasoning,
792 auto_selection,
793 initial_routed_usage,
794 routing_source: _,
795 } = planned;
796 let effective_reasoning_tier = selected_reasoning_effort
797 .unwrap_or(prepare.reasoning_effort)
798 .normalize_for_route(
799 effective_provider,
800 &turn_route.candidate.endpoint().base_url,
801 &turn_route.model,
802 );
803 let effective_reasoning_receipt = reasoning_effort_receipt_for_route(
804 effective_reasoning_tier,
805 effective_provider,
806 &turn_route.candidate.endpoint().base_url,
807 &turn_route.model,
808 );
809
810 let mut usage = UnacceptedDispatchUsage {
811 scope: prepare.cost_scope,
812 batch: Some(initial_routed_usage.clone()),
813 };
814 let op = Op::SendMessage(TurnSpec {
815 max_output_tokens: None,
816 content: prepare.content.clone(),
817 images: Vec::new(),
818 mode: prepare.mode,
819 route: Box::new(turn_route),
820 compaction: Box::new(turn_compaction.clone()),
821 initial_routed_usage: Box::new(initial_routed_usage),
822 goal_objective: prepare.goal_objective.clone(),
823 goal_token_budget: prepare.goal_token_budget,
824 goal_status: prepare.goal_status,
825 reasoning_effort: effective_reasoning_effort,
826 reasoning_effort_auto: auto_controls_reasoning,
827 auto_model: prepare.auto_model,
828 allow_shell: prepare.allow_shell,
829 trust_mode: prepare.trust_mode,
830 auto_approve: prepare.auto_approve,
831 approval_mode: prepare.approval_mode,
832 translation_enabled: prepare.translation_enabled,
833 allowed_tools: prepare.allowed_tools.clone(),
834 dynamic_tools: Vec::new(),
835 hook_executor: prepare.hook_executor.clone(),
836 verbosity: prepare.verbosity.clone(),
837 provenance: prepare.provenance,
838 });
839 // Reserve capacity off the render thread, but do not let Engine start
840 // until the completion callback has installed the UI's acceptance state.
841 // Separate completion/event mailboxes otherwise allow TurnStarted (or
842 // TurnComplete) to arrive before a callback that resets those newer facts.
843 let permit = match engine_handle.tx_op.clone().reserve_owned().await {
844 Ok(permit) => permit,
845 Err(err) => return build_dispatch_error_closure(prepare, recovery, err.to_string()),
846 };
847 let outcome = UserDispatchOutcome {
848 turn_compaction,
849 effective_provider,
850 effective_model,
851 effective_provider_identity,
852 effective_provider_label,
853 effective_reasoning_effort: effective_reasoning_receipt,
854 auto_selection,
855 };
856 Box::new(move |app, current_engine, config| {
857 // Admission stays serialized by this flag until its callback retires,
858 // even if the user replaced the Engine/session while routing waited.
859 app.dispatch_in_flight = false;
860 // This request has no admitted Op and cannot emit TurnComplete. Retire
861 // its local cancellation even after replacement, but leave a previous
862 // admitted turn's suppression for that turn's terminal event to retire.
863 if !prepare.snapshot.suppress_stream_events_until_turn_complete {
864 app.suppress_stream_events_until_turn_complete = false;
865 }
866 if !engine_handle.tx_op.same_channel(&current_engine.tx_op)
867 || prepare.cost_scope != crate::cost_status::scope_token()
868 {
869 anyhow::bail!("Message dispatch belongs to a previous engine or session");
870 }
871 if !app.is_loading || engine_handle.tx_op.is_closed() {
872 let error = if engine_handle.tx_op.is_closed() {
873 "Engine stopped before accepting the message"
874 } else {
875 "Message dispatch was cancelled before it reached the engine"
876 };
877 return build_dispatch_error_closure(prepare, recovery, error.to_string())(
878 app,
879 &engine_handle,
880 config,
881 );
882 }
883 build_dispatch_success_closure(prepare, outcome)(app, &engine_handle, config)?;
884 // Existing Engine admission binds cancellation controls and the Op in
885 // one FIFO. No await separates the UI checkpoint from this handoff.
886 engine_handle.send_reserved_op(permit, op);
887 drop(usage.batch.take());
888 Ok(())
889 })
890 }
891
892 pub(crate) fn build_dispatch_success_closure(
893 prepare: UserDispatchPrepare,
894 outcome: UserDispatchOutcome,
895 ) -> crate::tui::app::DispatchApplyFn {
896 Box::new(
897 move |app: &mut App, engine_handle: &EngineHandle, config: &Config| -> anyhow::Result<()> {
898 app.dispatch_in_flight = false;
899 prepare.paused_dispatch.apply(app, engine_handle);
900
901 let dispatch_started_at = Instant::now();
902 app.is_loading = true;
903 app.dispatch_started_at = Some(dispatch_started_at);
904 app.runtime_turn_status = None;
905 // last_send_at was already anchored in the sync prepare phase so
906 // the tail-flash starts together with the visible user cell.
907 app.last_submitted_prompt = Some(prepare.message.display.clone());
908 app.clear_receipt();
909 app.tool_evidence.clear();
910
911 app.system_prompt = Some(build_app_system_prompt_with_goal(
912 app,
913 config,
914 app.goal.objective.as_deref(),
915 ));
916 // History and api_messages were already appended in the sync prepare
917 // phase; record references now that the turn is accepted.
918 app.record_context_references(
919 prepare.history_cell,
920 prepare.message_index,
921 prepare.references,
922 );
923 app.scroll_to_bottom();
924
925 app.last_effective_reasoning_effort = Some(outcome.effective_reasoning_effort);
926 if prepare.auto_model {
927 app.last_effective_model = Some(outcome.effective_model.clone());
928 app.last_effective_provider = Some(outcome.effective_provider);
929 app.last_effective_provider_identity =
930 Some(outcome.effective_provider_identity.clone());
931 if let Some(selection) = outcome.auto_selection.as_ref() {
932 app.last_auto_route_receipt = selection.receipt.clone();
933 let status = app
934 .tr(MessageId::AutoRouteSelectedToast)
935 .replace("{provider}", &outcome.effective_provider_label)
936 .replace("{model}", &outcome.effective_model)
937 .replace("{source}", selection.source.label());
938 app.push_status_toast(status, StatusToastLevel::Info, Some(6_000));
939 }
940 } else {
941 app.last_effective_model = None;
942 app.last_effective_provider = None;
943 app.last_effective_provider_identity = None;
944 app.last_auto_route_receipt = None;
945 }
946 app.pending_auto_route_receipt = outcome
947 .auto_selection
948 .as_ref()
949 .and_then(|selection| selection.receipt.clone());
950 app.pending_turn_route = Some((
951 outcome.effective_provider,
952 outcome.effective_model,
953 prepare.auto_model,
954 ));
955
956 maybe_warn_context_pressure_for_config(app, &outcome.turn_compaction);
957 if let Some(message) = crate::plugins::plugin_reload_nudge(
958 app.plugin_registry.as_ref(),
959 &mut app.plugin_reload_nudge_stamp,
960 ) {
961 app.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
962 }
963 app.session.last_prompt_tokens = None;
964 app.session.last_completion_tokens = None;
965 app.session.last_prompt_cache_hit_tokens = None;
966 app.session.last_prompt_cache_miss_tokens = None;
967 app.session.last_reasoning_replay_tokens = None;
968
969 if let Ok(manager) = SessionManager::default_location()
970 && let Ok(session) = build_session_snapshot(app, &manager)
971 {
972 if app.current_session_id.is_none() {
973 app.current_session_id = Some(session.metadata.id.clone());
974 }
975 if let Err(err) = persist_with_pending_work_boundary(
976 app,
977 PersistRequest::SaveCheckpoint { session },
978 ) {
979 app.status_message = Some(format!(
980 "To-do list update pending: turn checkpoint could not be queued ({err})"
981 ));
982 }
983 }
984
985 Ok(())
986 },
987 )
988 }
989
990 /// Missing-credential / auth preflight failures must keep the transcript echo.
991 /// The user already submitted; rolling the HistoryCell::User back and restoring
992 /// the composer hides the turn and makes first-run feel broken.
993 pub(crate) fn is_missing_credential_dispatch_error(error: &str) -> bool {
994 let lower = error.to_ascii_lowercase();
995 lower.contains("api key not found")
996 || lower.contains("access token")
997 || (lower.contains("credential")
998 && (lower.contains("not found")
999 || lower.contains("missing")
1000 || lower.contains("unavailable")
1001 || lower.contains("unsupported")))
1002 }
1003
1004 pub(crate) fn build_dispatch_error_closure(
1005 prepare: UserDispatchPrepare,
1006 recovery: DispatchRecovery,
1007 error: String,
1008 ) -> crate::tui::app::DispatchApplyFn {
1009 Box::new(
1010 move |app: &mut App,
1011 _engine_handle: &EngineHandle,
1012 _config: &Config|
1013 -> anyhow::Result<()> {
1014 app.dispatch_in_flight = false;
1015 // No operation was admitted, including route/reservation failures:
1016 // retire only cancellation introduced by this dispatch. A previous
1017 // admitted turn may still need to suppress its queued events.
1018 if !prepare.snapshot.suppress_stream_events_until_turn_complete {
1019 app.suppress_stream_events_until_turn_complete = false;
1020 }
1021 if prepare.cost_scope != crate::cost_status::scope_token() {
1022 anyhow::bail!("Message dispatch belongs to a previous session");
1023 }
1024 app.remote_control.fail_active_dispatch(&error);
1025 // Roll back the optimistic sync prepare mutations.
1026 app.is_loading = prepare.snapshot.is_loading;
1027 app.runtime_turn_status = prepare.snapshot.runtime_turn_status.clone();
1028 app.receipt_text = prepare.snapshot.receipt_text.clone();
1029 app.receipt_started_at = prepare.snapshot.receipt_started_at;
1030 app.tool_evidence = prepare.snapshot.tool_evidence.clone();
1031 let keep_user_echo = is_missing_credential_dispatch_error(&error);
1032 if keep_user_echo {
1033 // Echo first: keep HistoryCell::User painted in prepare. Drop only
1034 // the unsent api_messages append and loading chrome.
1035 app.truncate_api_messages(prepare.snapshot.api_messages_len);
1036 app.last_send_at = prepare.snapshot.last_send_at;
1037 } else {
1038 app.history.truncate(prepare.snapshot.history_len);
1039 app.prune_transcript_index_state(prepare.snapshot.history_len);
1040 app.history_revisions
1041 .truncate(prepare.snapshot.history_revisions_len);
1042 app.history_version = prepare.snapshot.history_version;
1043 app.truncate_api_messages(prepare.snapshot.api_messages_len);
1044 app.last_send_at = prepare.snapshot.last_send_at;
1045 }
1046 app.needs_redraw = true;
1047
1048 match recovery {
1049 DispatchRecovery::Immediate => {
1050 if keep_user_echo {
1051 keep_failed_immediate_submit_echo(app, prepare.message, &error);
1052 } else {
1053 restore_failed_immediate_submit(
1054 app,
1055 prepare.message,
1056 &anyhow::Error::msg(error.clone()),
1057 );
1058 }
1059 }
1060 DispatchRecovery::Queued { restore_index } => {
1061 restore_queued_message(app, restore_index, prepare.message);
1062 app.status_message = Some(
1063 app.tr(MessageId::DispatchFailedQueued)
1064 .replace("{error}", &error)
1065 .replace("{count}", &app.queued_message_count().to_string()),
1066 );
1067 }
1068 DispatchRecovery::Draft => {
1069 restore_queued_or_draft_message(app, DispatchRecovery::Draft, prepare.message);
1070 app.status_message = Some(format!(
1071 "Message dispatch failed ({error}); queued draft restored"
1072 ));
1073 }
1074 DispatchRecovery::Initial => {
1075 if keep_user_echo {
1076 keep_failed_immediate_submit_echo(app, prepare.message, &error);
1077 } else {
1078 let initial_error = app
1079 .tr(MessageId::DispatchFailedInitial)
1080 .replace("{error}", &error);
1081 restore_failed_immediate_submit(
1082 app,
1083 prepare.message,
1084 &anyhow::Error::msg(initial_error),
1085 );
1086 }
1087 }
1088 }
1089
1090 Err(anyhow::Error::msg(error))
1091 },
1092 )
1093 }
1094
1095 pub(crate) fn parse_queue_send_command(input: &str) -> Option<Result<usize, String>> {
1096 let rest = strip_queue_command_prefix(input.trim())?;
1097 let mut parts = rest.split_whitespace();
1098 let action = parts.next()?;
1099 if !action.eq_ignore_ascii_case("send") && !action.eq_ignore_ascii_case("now") {
1100 return None;
1101 }
1102 let Some(raw_index) = parts.next() else {
1103 return Some(Err("Usage: /queue send <n>".to_string()));
1104 };
1105 if parts.next().is_some() {
1106 return Some(Err("Usage: /queue send <n>".to_string()));
1107 }
1108 let Ok(index) = raw_index.parse::<usize>() else {
1109 return Some(Err("Use a positive number".to_string()));
1110 };
1111 if index == 0 {
1112 return Some(Err("Use 1 or more".to_string()));
1113 }
1114 Some(Ok(index - 1))
1115 }
1116
1117 pub(crate) fn strip_queue_command_prefix(input: &str) -> Option<&str> {
1118 for prefix in ["/queue", "/queued"] {
1119 if let Some(rest) = input.strip_prefix(prefix)
1120 && (rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace))
1121 {
1122 return Some(rest);
1123 }
1124 }
1125 None
1126 }
1127
1128 pub(crate) async fn steer_user_message(
1129 app: &mut App,
1130 config: &Config,
1131 engine_handle: &EngineHandle,
1132 mut message: QueuedMessage,
1133 ) -> Result<bool> {
1134 let stop_words = config.stop_words();
1135 if is_stop_word(&message.display, &stop_words).is_some() {
1136 engine_handle.cancel();
1137 app.stopped_turn = true;
1138 app.status_message = Some("Turn stopped. Tool calls blocked for this turn.".to_string());
1139 return Ok(false);
1140 }
1141 app.stopped_turn = false;
1142 // Same-turn steering is an engine-bound external-user path just like a
1143 // fresh dispatch. Run the mutable gate exactly once on the blocking pool
1144 // before pause state, history, references, or engine input are touched.
1145 if app
1146 .hooks
1147 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
1148 {
1149 let context = app.base_hook_context().with_message(&message.display);
1150 let strict_gates = app
1151 .hooks
1152 .matched_strict_gate_labels(crate::hooks::HookEvent::MessageSubmit, &context);
1153 let hooks = app.hooks.clone();
1154 let original_text = message.display.clone();
1155 let outcome = match tokio::task::spawn_blocking(move || {
1156 hooks.execute_message_submit_transform_for_dispatch(&context, &original_text)
1157 })
1158 .await
1159 {
1160 Ok(outcome) => outcome,
1161 Err(error) => {
1162 tracing::error!(target: "hooks", %error, "steer message_submit executor task was lost");
1163 lost_message_submit_outcome(&strict_gates)
1164 }
1165 };
1166 if !apply_message_submit_outcome(app, &mut message, outcome) {
1167 return Ok(false);
1168 }
1169 }
1170
1171 let paused_snapshot = snapshot_steer_paused_state(app);
1172 let paused_dispatch = plan_paused_command_message(app, &message.display);
1173 let paused_note = paused_dispatch.note().map(str::to_string);
1174 paused_dispatch.apply(app, engine_handle);
1175 let cwd = std::env::current_dir().ok();
1176 // Same single-submit cache as the other send path — see #4067 follow-up.
1177 let mut git_cache = crate::tui::git_mention::GitMentionCache::default();
1178 let completion_index = app.composer.mention_discovery.fuzzy_candidates(
1179 &app.workspace,
1180 &app.composer.mention_cwd,
1181 app.mention_walk_depth,
1182 app.workspace_follow_symlinks,
1183 );
1184 let references = crate::tui::file_mention::context_references_from_input_cached(
1185 &message.display,
1186 &app.workspace,
1187 cwd.clone(),
1188 &mut git_cache,
1189 completion_index,
1190 );
1191 let mut content = queued_message_content_for_app(app, &message, cwd, &mut git_cache)?;
1192 if let Some(note) = paused_note.as_deref() {
1193 content.push_str(note);
1194 }
1195 let message_index = app.api_messages.len();
1196
1197 // A foreground shell blocks the turn loop that consumes steer input.
1198 // Ask the shared shell manager to detach it before enqueueing the steer so
1199 // the loop can leave the foreground wait and process this message (#4930).
1200 if active_foreground_shell_running(app)
1201 && let Err(err) = request_active_foreground_shell_background(app)
1202 {
1203 restore_steer_paused_state(app, &paused_snapshot);
1204 engine_handle.set_paused(paused_snapshot.paused);
1205 return Err(err.context("could not move foreground shell to /jobs before steering"));
1206 }
1207
1208 if let Err(err) = engine_handle.steer(content.clone()).await {
1209 restore_steer_paused_state(app, &paused_snapshot);
1210 engine_handle.set_paused(paused_snapshot.paused);
1211 return Err(err);
1212 }
1213 app.last_submitted_prompt = Some(message.display.clone());
1214
1215 // #6190: the steer channel accepting the text is not the turn accepting
1216 // it. The engine commits a steer at the next step boundary and discards
1217 // one whose turn has already moved on, so painting a settled cell and
1218 // pushing `api_messages` here produced two defects at once: the cell sat
1219 // above the assistant content the record places before it, and a dropped
1220 // steer left a transcript entry the model never saw. Hold it as in-flight
1221 // instead — it renders in the "sending into turn" preview until the
1222 // engine's own `SessionUpdated` shows it, which is also where it learns
1223 // its real message index.
1224 app.inflight_steers
1225 .push_back(crate::tui::app::InflightSteer {
1226 message,
1227 content,
1228 sent_after_index: message_index,
1229 references,
1230 });
1231 app.needs_redraw = true;
1232
1233 app.status_message = Some("Steering current turn...".to_string());
1234 Ok(true)
1235 }
1236
1237 /// Promote every in-flight steer the engine's record now contains.
1238 ///
1239 /// Called from `apply_engine_session_projection` after the projection lands,
1240 /// so the transcript cell is appended in the position the record gives it:
1241 /// below the assistant work that preceded the steer, as the newest entry.
1242 /// Matching is on the exact text handed to `EngineHandle::steer`, which the
1243 /// engine stores as the accepted user message's first text block, searched
1244 /// from the index the steer was sent after so an identical earlier message
1245 /// cannot claim it.
1246 pub(crate) fn settle_accepted_steers(app: &mut App) {
1247 if app.inflight_steers.is_empty() {
1248 return;
1249 }
1250 let mut claimed: Vec<usize> = Vec::new();
1251 let mut unsettled = VecDeque::new();
1252 for steer in std::mem::take(&mut app.inflight_steers) {
1253 let Some(index) = accepted_steer_index(app, &steer, &claimed) else {
1254 unsettled.push_back(steer);
1255 continue;
1256 };
1257 claimed.push(index);
1258 // Settle the streaming thinking/tool content that chronologically
1259 // preceded the steer before the steer's own cell is appended.
1260 app.flush_active_cell();
1261 let display = format!("+ {}", steer.message.display);
1262 let history_cell = paint_user_turn_cell(app, &steer.message, display);
1263 app.record_context_references(history_cell, index, steer.references);
1264 app.needs_redraw = true;
1265 }
1266 app.inflight_steers = unsettled;
1267 }
1268
1269 fn accepted_steer_index(
1270 app: &App,
1271 steer: &crate::tui::app::InflightSteer,
1272 claimed: &[usize],
1273 ) -> Option<usize> {
1274 let start = steer.sent_after_index.min(app.api_messages.len());
1275 app.api_messages
1276 .iter()
1277 .enumerate()
1278 .skip(start)
1279 .find(|(index, message)| {
1280 !claimed.contains(index)
1281 && message.role == Role::User
1282 && matches!(
1283 message.content.first(),
1284 Some(ContentBlock::Text { text, .. }) if text == &steer.content
1285 )
1286 })
1287 .map(|(index, _)| index)
1288 }
1289
1290 /// A turn that ended without accepting a steer must not swallow it (#6190,
1291 /// #6297). The message becomes a queued follow-up — the queue is the one path
1292 /// that actually drains into a turn, where the engine's context-pressure gate
1293 /// sees it like any other send — instead of a display-only "rejected" string
1294 /// that nothing ever dispatches.
1295 pub(crate) fn settle_unaccepted_steers_at_turn_end(app: &mut App) {
1296 if app.inflight_steers.is_empty() {
1297 return;
1298 }
1299 let deferred = std::mem::take(&mut app.inflight_steers);
1300 app.queued_messages
1301 .extend(deferred.into_iter().map(|steer| steer.message));
1302 app.needs_redraw = true;
1303 }
1304
1305 pub(crate) fn snapshot_steer_paused_state(app: &App) -> SteerPausedSnapshot {
1306 SteerPausedSnapshot {
1307 paused: app.paused,
1308 pausable: app.pausable,
1309 paused_goal_objective: app.paused_goal_objective.clone(),
1310 objective: app.goal.objective.clone(),
1311 tokens_used: app.goal.tokens_used,
1312 time_used_seconds: app.goal.time_used_seconds,
1313 continuation_count: app.goal.continuation_count,
1314 }
1315 }
1316
1317 pub(crate) fn restore_steer_paused_state(app: &mut App, snapshot: &SteerPausedSnapshot) {
1318 app.paused = snapshot.paused;
1319 app.pausable = snapshot.pausable;
1320 app.paused_goal_objective = snapshot.paused_goal_objective.clone();
1321 app.goal.objective = snapshot.objective.clone();
1322 app.goal.tokens_used = snapshot.tokens_used;
1323 app.goal.time_used_seconds = snapshot.time_used_seconds;
1324 app.goal.continuation_count = snapshot.continuation_count;
1325 }
1326
1327 pub(crate) async fn attempt_steer_with_queue_fallback(
1328 app: &mut App,
1329 config: &Config,
1330 engine_handle: &EngineHandle,
1331 message: QueuedMessage,
1332 recovery: DispatchRecovery,
1333 ) -> bool {
1334 match steer_user_message(app, config, engine_handle, message.clone()).await {
1335 Ok(true) => {
1336 app.push_status_toast(
1337 app.tr(MessageId::ToastSentIntoTurn).into_owned(),
1338 StatusToastLevel::Info,
1339 Some(1_500),
1340 );
1341 true
1342 }
1343 Ok(false) => {
1344 restore_queued_or_draft_message(app, recovery, message);
1345 app.push_status_toast(
1346 app.tr(MessageId::ToastHookBlockedFollowUp).into_owned(),
1347 StatusToastLevel::Warning,
1348 Some(4_000),
1349 );
1350 false
1351 }
1352 Err(err) => {
1353 restore_queued_or_draft_message(app, recovery, message);
1354 let status = format!("{} ({err})", app.tr(MessageId::ToastCouldNotSendIntoTurn));
1355 app.status_message = Some(status.clone());
1356 app.push_status_toast(status, StatusToastLevel::Warning, Some(4_000));
1357 false
1358 }
1359 }
1360 }
1361
1362 /// Park a draft on the queued-messages bucket for dispatch after TurnComplete.
1363 /// Unlike a steer, the message is NOT forwarded immediately — it waits for
1364 /// the current turn to finish, then dispatches as a normal user message.
1365 pub(crate) async fn queue_follow_up(app: &mut App, message: QueuedMessage) -> Result<()> {
1366 let mut message = message;
1367 echo_queued_user_turn(app, &mut message);
1368 enqueue_offline_message(app, message);
1369 let toast = queued_follow_up_toast(app);
1370 app.status_message = Some(toast.clone());
1371 app.push_status_toast(toast, StatusToastLevel::Info, Some(3_000));
1372 Ok(())
1373 }
1374
1375 fn queued_follow_up_toast(app: &App) -> String {
1376 if app.offline_mode {
1377 return app.tr(MessageId::ToastQueuedOffline).into_owned();
1378 }
1379 let count = app.queued_message_count();
1380 if count <= 1 {
1381 app.tr(MessageId::ToastQueuedFollowUp).into_owned()
1382 } else {
1383 app.tr(MessageId::ToastQueuedFollowUpCount)
1384 .replace("{count}", &count.to_string())
1385 }
1386 }
1387
1388 pub(crate) async fn dispatch_composer_message(
1389 app: &mut App,
1390 config: &Config,
1391 engine_handle: &EngineHandle,
1392 message: QueuedMessage,
1393 recovery: DispatchRecovery,
1394 action: ComposerSubmitAction,
1395 ) -> Result<()> {
1396 // The ordinary web mirror does not block local prompts. Isolated Runtime
1397 // Chat is different: it owns a provider-backed native turn that is not
1398 // reflected by the interactive engine's `is_loading` bit. Preserve the
1399 // local message in the queue until that exact turn and its terminal relay
1400 // receipt have settled, so one attached run never has two inference loops.
1401 if app.remote_control.runtime_chat_blocks_local_dispatch() {
1402 let mut message = message;
1403 echo_queued_user_turn(app, &mut message);
1404 enqueue_offline_message(app, message);
1405 let queued = app
1406 .tr(MessageId::AgentRailQueuedCount)
1407 .replace("{count}", &app.queued_message_count().to_string());
1408 let status = format!(
1409 "Runtime Chat · {} · {queued}",
1410 app.tr(MessageId::PhaseFinishing)
1411 );
1412 app.push_status_toast(status, StatusToastLevel::Info, Some(4_000));
1413 return Ok(());
1414 }
1415
1416 // Agent focus: the composer addresses one child's fork, not the main
1417 // session. The follow-up is real runtime work (Op::FollowUpSubAgent); the
1418 // main transcript keeps a receipt line and the focused view echoes the
1419 // message until the child's own transcript carries it.
1420 if let Some(focus) = app.agent_focus.as_ref() {
1421 let agent_id = focus.agent_id.clone();
1422 let label = focus.label.clone();
1423 let text = message.display.clone();
1424 crate::tui::agent_focus::echo_user_follow_up(app, &text);
1425 let receipt = app
1426 .tr(codewhale_localization::MessageId::AgentFocusFollowUpQueued)
1427 .replace("{agent}", &label);
1428 app.push_history_cell(crate::tui::history::HistoryCell::System { content: receipt });
1429 // #6150: the input path never awaits a full op channel. The follow-up
1430 // is retryable; a rejected send surfaces immediately.
1431 if let Err(err) = engine_handle.try_send(crate::core::ops::Op::FollowUpSubAgent {
1432 agent_id: agent_id.clone(),
1433 text,
1434 }) {
1435 let reason = if err
1436 .downcast_ref::<tokio::sync::mpsc::error::TrySendError<crate::core::ops::Op>>()
1437 .is_some_and(|e| matches!(e, tokio::sync::mpsc::error::TrySendError::Full(_)))
1438 {
1439 "engine busy"
1440 } else {
1441 "engine unavailable"
1442 };
1443 let failed = app
1444 .tr(codewhale_localization::MessageId::AgentFocusFollowUpFailed)
1445 .replace("{agent}", &label)
1446 .replace("{reason}", reason);
1447 app.status_message = Some(failed.clone());
1448 app.push_status_toast(failed, StatusToastLevel::Warning, Some(5_000));
1449 }
1450 return Ok(());
1451 }
1452 let disposition = match action {
1453 ComposerSubmitAction::Submit(disposition) => disposition,
1454 ComposerSubmitAction::SendQueuedNow | ComposerSubmitAction::Noop => {
1455 // The caller extracted a non-empty input, so these can only arise
1456 // if state changed between key resolution and dispatch. Queueing
1457 // is lossless and preserves ordering in that narrow race.
1458 SubmitDisposition::Queue
1459 }
1460 };
1461 match disposition {
1462 SubmitDisposition::Immediate => {
1463 let _ =
1464 dispatch_user_message_with_recovery(app, config, engine_handle, message, recovery)
1465 .await;
1466 Ok(())
1467 }
1468 SubmitDisposition::Queue => {
1469 let mut message = message;
1470 echo_queued_user_turn(app, &mut message);
1471 enqueue_offline_message(app, message);
1472 // A second, empty Enter inside the window sends this now.
1473 app.arm_double_tap_window();
1474 let toast = queued_follow_up_toast(app);
1475 app.status_message = Some(toast.clone());
1476 app.push_status_toast(toast, StatusToastLevel::Info, Some(3_000));
1477 Ok(())
1478 }
1479 SubmitDisposition::Steer => {
1480 attempt_steer_with_queue_fallback(app, config, engine_handle, message, recovery).await;
1481 Ok(())
1482 }
1483 SubmitDisposition::QueueFollowUp => queue_follow_up(app, message).await,
1484 }
1485 }
1486
1487 #[cfg(test)]
1488 pub(crate) async fn submit_or_steer_message(
1489 app: &mut App,
1490 config: &Config,
1491 engine_handle: &EngineHandle,
1492 message: QueuedMessage,
1493 recovery: DispatchRecovery,
1494 ) -> Result<()> {
1495 let action = ComposerSubmitAction::Submit(app.decide_submit_disposition());
1496 dispatch_composer_message(app, config, engine_handle, message, recovery, action).await
1497 }
1498
1499 /// Drain `app.pending_steers` into a single `QueuedMessage` ready for
1500 /// `dispatch_user_message`. Returns `None` if the queue was empty (caller
1501 /// then falls back to `app.queued_messages`). Skill instruction is taken
1502 /// from the first message that supplies one — multiple steers shouldn't
1503 /// double-up the system framing.
1504 pub(crate) fn merge_pending_steers(app: &mut App) -> Option<QueuedMessage> {
1505 let drained = app.drain_pending_steers();
1506 if drained.is_empty() {
1507 return None;
1508 }
1509 if drained.len() == 1 {
1510 return drained.into_iter().next();
1511 }
1512 let mut skill_instruction: Option<String> = None;
1513 let mut skill_provenance = None;
1514 let mut bodies: Vec<String> = Vec::with_capacity(drained.len());
1515 for msg in drained {
1516 if skill_instruction.is_none() {
1517 skill_instruction = msg.skill_instruction;
1518 skill_provenance = msg.skill_provenance;
1519 }
1520 bodies.push(msg.display);
1521 }
1522 Some(
1523 QueuedMessage::new(bodies.join("\n\n"), skill_instruction)
1524 .with_skill_provenance(skill_provenance),
1525 )
1526 }
1527
1527 lines RUST