返回 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
8 pub(crate) fn dispatch_hotbar_slot(
9 app: &mut App,
10 config: &Config,
11 slot: u8,
12 ) -> Result<Option<HotbarDispatch>> {
13 let known_action_ids = app
14 .hotbar_actions
15 .iter()
16 .map(|action| action.id())
17 .collect::<Vec<_>>();
18 let bindings = config.resolve_hotbar_bindings(&known_action_ids).bindings;
19 let Some(action_id) = bindings
20 .iter()
21 .find(|binding| binding.slot == slot)
22 .map(|binding| binding.action.clone())
23 else {
24 return Ok(None);
25 };
26
27 let Some(action) = app.hotbar_actions.get(&action_id) else {
28 app.status_message = Some(format!(
29 "Hotbar slot {slot} action is not available: {action_id}"
30 ));
31 app.needs_redraw = true;
32 return Ok(Some(HotbarDispatch::Handled));
33 };
34
35 if let Some(reason) = action.disabled_reason(app) {
36 app.status_message = Some(format!(
37 "Hotbar slot {slot} action is not available: {reason}"
38 ));
39 app.needs_redraw = true;
40 return Ok(Some(HotbarDispatch::Handled));
41 }
42
43 action.dispatch(app).map(Some)
44 }
45
46 pub(crate) fn queued_ui_to_session(msg: &QueuedMessage) -> QueuedSessionMessage {
47 QueuedSessionMessage {
48 display: msg.display.clone(),
49 skill_instruction: msg.skill_instruction.clone(),
50 skill_provenance: msg.skill_provenance.clone(),
51 }
52 }
53
54 pub(crate) fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage {
55 QueuedMessage {
56 display: msg.display,
57 skill_instruction: msg.skill_instruction,
58 skill_provenance: msg.skill_provenance,
59 }
60 }
61
62 pub(crate) fn enqueue_offline_message(app: &mut App, message: QueuedMessage) {
63 app.queue_message(message);
64 persist_offline_queue_state(app);
65 }
66
67 pub(crate) fn push_assistant_message(
68 app: &mut App,
69 text: String,
70 thinking: Option<String>,
71 tool_uses: PendingToolUses,
72 ) {
73 let mut blocks = Vec::new();
74 if let Some(thinking) = thinking {
75 blocks.push(ContentBlock::Thinking {
76 thinking,
77 signature: None,
78 });
79 }
80 if !text.is_empty() {
81 blocks.push(ContentBlock::Text {
82 text,
83 cache_control: None,
84 });
85 }
86 for (id, name, input) in tool_uses {
87 blocks.push(ContentBlock::ToolUse {
88 id,
89 name,
90 input,
91 caller: None,
92 });
93 }
94
95 let has_sendable_content = blocks.iter().any(|block| {
96 matches!(
97 block,
98 ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
99 )
100 });
101 if has_sendable_content {
102 app.api_messages.push(Message {
103 role: "assistant".to_string(),
104 content: blocks,
105 });
106 }
107 }
108
109 pub(crate) fn replace_matching_assistant_text(
110 app: &mut App,
111 original_text: &str,
112 translated_text: String,
113 ) -> bool {
114 for message in app.api_messages.iter_mut().rev() {
115 if message.role != "assistant" && message.role != crate::models::INTERRUPTED_ASSISTANT_ROLE
116 {
117 continue;
118 }
119 for block in &mut message.content {
120 if let ContentBlock::Text { text, .. } = block
121 && text == original_text
122 {
123 *text = translated_text;
124 return true;
125 }
126 }
127 }
128 false
129 }
130
131 pub(crate) fn build_queued_message(app: &mut App, input: String) -> QueuedMessage {
132 let skill_instruction = app.active_skill.take();
133 let skill_provenance = app.active_skill_provenance.take();
134 QueuedMessage::new(input, skill_instruction).with_skill_provenance(skill_provenance)
135 }
136
137 pub(crate) async fn submit_initial_input_if_ready(
138 app: &mut App,
139 config: &Config,
140 engine_handle: &EngineHandle,
141 ) -> Result<()> {
142 if !app.auto_submit_initial_input {
143 return Ok(());
144 }
145
146 if app.onboarding != OnboardingState::None {
147 if app.status_message.is_none() && !app.input.trim().is_empty() {
148 app.status_message = Some(INITIAL_PROMPT_DEFERRED_STATUS.to_string());
149 }
150 return Ok(());
151 }
152
153 app.auto_submit_initial_input = false;
154 if let Some(input) = app.submit_input() {
155 if app.status_message.as_deref() == Some(INITIAL_PROMPT_DEFERRED_STATUS) {
156 app.status_message = None;
157 }
158 let queued = build_queued_message(app, input);
159 dispatch_user_message_with_recovery(
160 app,
161 config,
162 engine_handle,
163 queued,
164 DispatchRecovery::Initial,
165 )
166 .await?;
167 }
168 Ok(())
169 }
170
171 pub(crate) fn message_from_submitted_input(
172 app: &mut App,
173 input: String,
174 ) -> (QueuedMessage, DispatchRecovery) {
175 if let Some(mut draft) = app.queued_draft.take() {
176 draft.display = input;
177 (draft, DispatchRecovery::Draft)
178 } else {
179 (
180 build_queued_message(app, input),
181 DispatchRecovery::Immediate,
182 )
183 }
184 }
185
186 pub(crate) fn take_next_queued_message(app: &mut App) -> Option<(QueuedMessage, DispatchRecovery)> {
187 if app.input.is_empty() {
188 return app.remove_queued_message(0).map(|message| {
189 (
190 message,
191 DispatchRecovery::Queued {
192 restore_index: Some(0),
193 },
194 )
195 });
196 }
197 None
198 }
199
200 pub(crate) async fn send_next_queued_message_now(
201 app: &mut App,
202 config: &Config,
203 engine_handle: &EngineHandle,
204 ) -> Result<bool> {
205 let Some((message, recovery)) = take_next_queued_message(app) else {
206 return Ok(false);
207 };
208 send_taken_queued_message_now(app, config, engine_handle, message, recovery).await?;
209 Ok(true)
210 }
211
212 pub(crate) async fn send_queued_message_at_index_now(
213 app: &mut App,
214 config: &Config,
215 engine_handle: &EngineHandle,
216 index: usize,
217 ) -> Result<bool> {
218 let Some(message) = app.remove_queued_message(index) else {
219 app.status_message = Some("Queued message not found".to_string());
220 return Ok(true);
221 };
222 send_taken_queued_message_now(
223 app,
224 config,
225 engine_handle,
226 message,
227 DispatchRecovery::Queued {
228 restore_index: Some(index),
229 },
230 )
231 .await?;
232 Ok(true)
233 }
234
235 pub(crate) async fn send_taken_queued_message_now(
236 app: &mut App,
237 config: &Config,
238 engine_handle: &EngineHandle,
239 message: QueuedMessage,
240 recovery: DispatchRecovery,
241 ) -> Result<()> {
242 if app.offline_mode {
243 restore_queued_or_draft_message(app, recovery, message);
244 app.status_message = Some(format!(
245 "Offline: {} queued follow-up(s) — /queue send <n>, /queue clear",
246 app.queued_message_count()
247 ));
248 return Ok(());
249 }
250
251 let display = message.display.clone();
252 if app.dispatch_in_flight {
253 // A spawned dispatch is still resolving route/sending its op (#4605):
254 // there is no turn to steer into yet. Re-queue; the completion/turn
255 // lifecycle will drive the next drain.
256 restore_queued_or_draft_message(app, recovery, message);
257 app.status_message = Some(format!(
258 "{} queued follow-up(s) — sends after current dispatch starts",
259 app.queued_message_count()
260 ));
261 return Ok(());
262 }
263 if app.is_loading {
264 match steer_user_message(app, config, engine_handle, message.clone()).await {
265 Ok(true) => app.push_status_toast(
266 "Sent queued follow-up into current turn",
267 StatusToastLevel::Info,
268 Some(1_500),
269 ),
270 Ok(false) => {
271 restore_queued_or_draft_message(app, recovery, message);
272 app.push_status_toast(
273 "message_submit hook blocked the follow-up; original queue/draft restored",
274 StatusToastLevel::Warning,
275 Some(4_000),
276 );
277 }
278 Err(err) => {
279 restore_queued_or_draft_message(app, recovery, message);
280 app.status_message = Some(format!(
281 "Steer failed ({err}); {} queued follow-up(s) — /queue send <n>, /queue clear",
282 app.queued_message_count()
283 ));
284 }
285 }
286 } else if let Err(_err) =
287 dispatch_user_message_with_recovery(app, config, engine_handle, message, recovery).await
288 {
289 // The completion closure re-queued the message and set the status.
290 } else {
291 app.status_message = Some(format!("Sent queued follow-up: {display}"));
292 }
293 Ok(())
294 }
295
296 pub(crate) fn queued_message_content_for_app(
297 app: &App,
298 message: &QueuedMessage,
299 cwd: Option<PathBuf>,
300 git_cache: &mut crate::tui::git_mention::GitMentionCache,
301 ) -> Result<String> {
302 if let Some(authority) = message.skill_provenance.as_ref() {
303 if authority.workspace != app.workspace {
304 anyhow::bail!("Queued plugin skill belongs to a different workspace and was denied");
305 }
306 crate::plugins::registry::verify_plugin_authority(authority).map_err(anyhow::Error::msg)?;
307 }
308 // Pass the process CWD explicitly so the resolver's two-pass logic can
309 // honor the user's launch directory when it differs from `--workspace`
310 // (issue #101 — file mentions silently routing to the wrong root).
311 // The completion index is the composer's already-built fuzzy scan: a
312 // bounded fallback for exact misses, with no submit-time tree walk (#4365).
313 let completion_index = app.composer.mention_discovery.fuzzy_candidates(
314 &app.workspace,
315 &app.composer.mention_cwd,
316 app.mention_walk_depth,
317 app.workspace_follow_symlinks,
318 );
319 let user_request = crate::tui::file_mention::user_request_with_file_mentions_cached(
320 &message.display,
321 &app.workspace,
322 cwd,
323 git_cache,
324 completion_index,
325 );
326 if let Some(skill_instruction) = message.skill_instruction.as_ref() {
327 Ok(format!(
328 "{skill_instruction}\n\n---\n\nUser request: {user_request}"
329 ))
330 } else {
331 Ok(user_request)
332 }
333 }
334
335 pub(crate) fn dispatch_completion_permit(
336 app: &App,
337 ) -> std::result::Result<
338 tokio::sync::mpsc::OwnedPermit<crate::tui::app::DispatchApplyFn>,
339 &'static str,
340 > {
341 let sender = app
342 .dispatch_completion_tx
343 .clone()
344 .ok_or("dispatch completion mailbox is unavailable")?;
345 sender.try_reserve_owned().map_err(|error| match error {
346 tokio::sync::mpsc::error::TrySendError::Full(_) => "dispatch completion mailbox is full",
347 tokio::sync::mpsc::error::TrySendError::Closed(_) => {
348 "dispatch completion mailbox is closed"
349 }
350 })
351 }
352
353 #[cfg(test)]
354 pub(crate) async fn dispatch_user_message(
355 app: &mut App,
356 config: &Config,
357 engine_handle: &EngineHandle,
358 message: QueuedMessage,
359 ) -> Result<()> {
360 dispatch_user_message_with_recovery(
361 app,
362 config,
363 engine_handle,
364 message,
365 DispatchRecovery::Immediate,
366 )
367 .await
368 }
369
370 pub(crate) async fn dispatch_user_message_with_recovery(
371 app: &mut App,
372 config: &Config,
373 engine_handle: &EngineHandle,
374 mut message: QueuedMessage,
375 recovery: DispatchRecovery,
376 ) -> Result<()> {
377 let stop_words = config.stop_words();
378 if is_stop_word(&message.display, &stop_words).is_some() {
379 engine_handle.cancel();
380 app.stopped_turn = true;
381 app.status_message = Some("Turn stopped. Tool calls blocked for this turn.".to_string());
382 return Ok(());
383 }
384 app.stopped_turn = false;
385
386 // #1364: run mutable `message_submit` hooks before dispatch. Hooks see the
387 // user's display text and may replace or block it before file mentions,
388 // skill wrapping, history, and model input are resolved.
389 // Fast-path skip when no hooks configured.
390 if app
391 .hooks
392 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
393 {
394 let context = app.base_hook_context().with_message(&message.display);
395 let strict_gates = app
396 .hooks
397 .matched_strict_gate_labels(crate::hooks::HookEvent::MessageSubmit, &context);
398 let hooks = app.hooks.clone();
399 let original_text = message.display.clone();
400
401 if app.dispatch_completion_tx.is_some() {
402 // The foreground transform is a gate, but its child wait belongs
403 // on the blocking pool, never on the terminal event loop. Result
404 // delivery reserves bounded mailbox capacity before any work or
405 // state mutation, so the recovery closure cannot be dropped.
406 let completion_permit = match dispatch_completion_permit(app) {
407 Ok(permit) => permit,
408 Err(error) => {
409 recover_unstarted_external_message(app, message, recovery, error);
410 return Err(anyhow::Error::msg(error));
411 }
412 };
413 app.dispatch_in_flight = true;
414 tokio::spawn(async move {
415 let outcome = match tokio::task::spawn_blocking(move || {
416 hooks.execute_message_submit_transform_for_dispatch(&context, &original_text)
417 })
418 .await
419 {
420 Ok(outcome) => outcome,
421 Err(error) => {
422 tracing::error!(target: "hooks", %error, "message_submit executor task was lost");
423 lost_message_submit_outcome(&strict_gates)
424 }
425 };
426 let apply: crate::tui::app::DispatchApplyFn = Box::new(
427 move |app: &mut App,
428 engine_handle: &EngineHandle,
429 config: &Config|
430 -> anyhow::Result<()> {
431 if !apply_message_submit_outcome(app, &mut message, outcome) {
432 app.dispatch_in_flight = false;
433 restore_message_submit_denial(app, message, recovery);
434 return Ok(());
435 }
436 let _ = start_user_dispatch(app, config, engine_handle, message, recovery);
437 Ok(())
438 },
439 );
440 completion_permit.send(apply);
441 });
442 return Ok(());
443 }
444
445 // Unit tests intentionally omit the event-loop completion channel.
446 // Keep those synchronous from the test's perspective while still
447 // running the blocking child wait off the async runtime worker.
448 let outcome = match tokio::task::spawn_blocking(move || {
449 hooks.execute_message_submit_transform_for_dispatch(&context, &original_text)
450 })
451 .await
452 {
453 Ok(outcome) => outcome,
454 Err(error) => {
455 tracing::error!(target: "hooks", %error, "message_submit executor task was lost");
456 lost_message_submit_outcome(&strict_gates)
457 }
458 };
459 if !apply_message_submit_outcome(app, &mut message, outcome) {
460 restore_message_submit_denial(app, message, recovery);
461 return Ok(());
462 }
463 }
464
465 if app.dispatch_completion_tx.is_some() {
466 return start_user_dispatch(app, config, engine_handle, message, recovery);
467 }
468
469 let prepare = match prepare_user_dispatch(app, config, message.clone()) {
470 Ok(prepare) => prepare,
471 Err(error) => {
472 recover_unstarted_external_message(app, message, recovery, &error.to_string());
473 return Err(error);
474 }
475 };
476 run_prepared_dispatch(app, config, engine_handle, prepare, recovery).await
477 }
478
479 pub(crate) fn lost_message_submit_outcome(
480 strict_gates: &[String],
481 ) -> crate::hooks::MessageSubmitOutcome {
482 if strict_gates.is_empty() {
483 crate::hooks::MessageSubmitOutcome::Unchanged {
484 warning: Some(
485 "message_submit hook executor did not run; submission continued because no strict gate matched"
486 .to_string(),
487 ),
488 }
489 } else {
490 crate::hooks::MessageSubmitOutcome::Blocked {
491 reason: "message_submit hook executor did not run; a strict gate blocked submission"
492 .to_string(),
493 }
494 }
495 }
496
497 pub(crate) fn prepare_user_dispatch(
498 app: &mut App,
499 config: &Config,
500 message: QueuedMessage,
501 ) -> Result<UserDispatchPrepare> {
502 let _ = app.maybe_nudge_for_planning_prompt(&message.display);
503
504 // Plan paused-command changes without touching App or the engine pause
505 // gate. Route selection can await and client preflight can fail; neither
506 // may resume or discard a paused command unless a turn is ready to send.
507 let paused_dispatch = plan_paused_command_message(app, &message.display);
508
509 let cwd = std::env::current_dir().ok();
510 // One cache for this submit: the references pass and the payload pass
511 // otherwise each shell out for `@git`/`@diff`, making git compute a large
512 // working-tree diff twice to attach it once (#4067 review follow-up).
513 let mut git_cache = crate::tui::git_mention::GitMentionCache::default();
514 let completion_index = app.composer.mention_discovery.fuzzy_candidates(
515 &app.workspace,
516 &app.composer.mention_cwd,
517 app.mention_walk_depth,
518 app.workspace_follow_symlinks,
519 );
520 let references = crate::tui::file_mention::context_references_from_input_cached(
521 &message.display,
522 &app.workspace,
523 cwd.clone(),
524 &mut git_cache,
525 completion_index,
526 );
527 let mut content = queued_message_content_for_app(app, &message, cwd, &mut git_cache)?;
528 if let Some(note) = paused_dispatch.note() {
529 content.push_str(note);
530 }
531 let (app_route_identity, route_config) = app_scoped_runtime_config(app, config);
532
533 let should_auto_resolve = auto_router::should_resolve_auto_model_selection(app);
534 let auto_router_context = auto_router::recent_auto_router_context(&app.api_messages);
535
536 // Capture the App state before any optimistic mutation so a failure can
537 // roll back cleanly.
538 let snapshot = UserDispatchSnapshot {
539 is_loading: app.is_loading,
540 runtime_turn_status: app.runtime_turn_status.clone(),
541 receipt_text: app.receipt_text.clone(),
542 receipt_started_at: app.receipt_started_at,
543 tool_evidence: app.tool_evidence.clone(),
544 history_len: app.history.len(),
545 history_revisions_len: app.history_revisions.len(),
546 history_version: app.history_version,
547 next_history_revision: app.next_history_revision,
548 api_messages_len: app.api_messages.len(),
549 last_send_at: app.last_send_at,
550 };
551
552 // --- Sync prepare: show the user message and spinner immediately so the
553 // event loop can repaint before network I/O (#4605). The async phase runs
554 // the auto-model route, compaction, and engine send off the render thread.
555 app.is_loading = true;
556 app.runtime_turn_status = None;
557 app.clear_receipt();
558 app.tool_evidence.clear();
559 app.needs_redraw = true;
560
561 let message_index = app.api_messages.len();
562 app.add_message(HistoryCell::User {
563 content: message.display.clone(),
564 });
565 let history_cell = app.history.len().saturating_sub(1);
566 app.scroll_to_bottom();
567 // Anchor the tail-flash to the moment the user message appears, not to
568 // the async dispatch completion (which can lag by a route plan). The
569 // failure path restores the pre-send timestamp from the snapshot.
570 app.last_send_at = Some(Instant::now());
571 app.api_messages.push(Message {
572 role: "user".to_string(),
573 content: vec![ContentBlock::Text {
574 text: content.clone(),
575 cache_control: None,
576 }],
577 });
578
579 let goal_objective = paused_dispatch.goal_objective(app);
580
581 Ok(UserDispatchPrepare {
582 message,
583 content,
584 references,
585 paused_dispatch,
586 app_route_identity,
587 route_config,
588 goal_objective,
589 goal_status: app.hunt.verdict.goal_status(),
590 goal_token_budget: app.hunt.token_budget,
591 mode: app.mode,
592 api_provider: app.api_provider,
593 app_model: app.model.clone(),
594 auto_model: app.auto_model,
595 reasoning_effort: app.reasoning_effort,
596 allow_shell: app.allow_shell,
597 trust_mode: app.trust_mode,
598 auto_approve: app_auto_approve_enabled(app),
599 approval_mode: app.approval_mode,
600 translation_enabled: app.translation_enabled,
601 allowed_tools: app.active_allowed_tools.clone(),
602 hook_executor: app.runtime_services.hook_executor.clone(),
603 verbosity: app.verbosity.clone(),
604 provenance: UserInputProvenance::ExternalUser,
605 auto_router_context,
606 should_auto_resolve,
607 auto_compact_user_configured: app.auto_compact_user_configured,
608 auto_compact: app.auto_compact,
609 auto_compact_threshold_percent: app.auto_compact_threshold_percent,
610 snapshot,
611 message_index,
612 history_cell,
613 })
614 }
615
616 pub(crate) fn start_user_dispatch(
617 app: &mut App,
618 config: &Config,
619 engine_handle: &EngineHandle,
620 message: QueuedMessage,
621 recovery: DispatchRecovery,
622 ) -> Result<()> {
623 let completion_permit = match dispatch_completion_permit(app) {
624 Ok(permit) => permit,
625 Err(error) => {
626 recover_unstarted_external_message(app, message, recovery, error);
627 return Err(anyhow::Error::msg(error));
628 }
629 };
630 let recovery_message = message.clone();
631 let prepare = match prepare_user_dispatch(app, config, message) {
632 Ok(prepare) => prepare,
633 Err(error) => {
634 recover_unstarted_external_message(app, recovery_message, recovery, &error.to_string());
635 return Err(error);
636 }
637 };
638 app.dispatch_in_flight = true;
639 tokio::spawn(spawned_dispatch_execute(
640 prepare,
641 recovery,
642 engine_handle.clone(),
643 completion_permit,
644 ));
645 Ok(())
646 }
647
648 pub(crate) async fn spawned_dispatch_execute(
649 prepare: UserDispatchPrepare,
650 recovery: DispatchRecovery,
651 engine_handle: EngineHandle,
652 completion_permit: tokio::sync::mpsc::OwnedPermit<crate::tui::app::DispatchApplyFn>,
653 ) {
654 let apply = spawned_dispatch_inner(prepare, recovery, engine_handle).await;
655 completion_permit.send(apply);
656 }
657
658 pub(crate) async fn spawned_dispatch_inner(
659 prepare: UserDispatchPrepare,
660 recovery: DispatchRecovery,
661 engine_handle: EngineHandle,
662 ) -> crate::tui::app::DispatchApplyFn {
663 // Bound in its own statement: the planner borrows `prepare`, and the error
664 // arm moves it into the failure closure.
665 let plan_result = plan_turn_route(TurnRoutePlanRequest {
666 route_config: &prepare.route_config,
667 app_route_identity: &prepare.app_route_identity,
668 api_provider: prepare.api_provider,
669 app_model: &prepare.app_model,
670 auto_model: prepare.auto_model,
671 reasoning_effort: prepare.reasoning_effort,
672 mode: prepare.mode,
673 content: &prepare.content,
674 display_text: &prepare.message.display,
675 auto_router_context: &prepare.auto_router_context,
676 should_auto_resolve: prepare.should_auto_resolve,
677 allow_auto_router_response_cache: true,
678 preflight_required: engine_handle.client_preflight_required(),
679 auto_compact_user_configured: prepare.auto_compact_user_configured,
680 auto_compact: prepare.auto_compact,
681 auto_compact_threshold_percent: prepare.auto_compact_threshold_percent,
682 })
683 .await;
684 let planned = match plan_result {
685 Ok(planned) => planned,
686 Err(err) => return build_dispatch_error_closure(prepare, recovery, err),
687 };
688
689 let PlannedTurnRoute {
690 route: turn_route,
691 compaction: turn_compaction,
692 effective_provider,
693 effective_model,
694 effective_provider_identity,
695 effective_provider_label,
696 selected_reasoning_effort,
697 effective_reasoning_effort,
698 auto_controls_reasoning,
699 auto_selection,
700 routing_source: _,
701 } = planned;
702 let effective_reasoning_tier = selected_reasoning_effort
703 .unwrap_or(prepare.reasoning_effort)
704 .normalize_for_route(
705 effective_provider,
706 &turn_route.candidate.endpoint().base_url,
707 &turn_route.model,
708 );
709 let effective_reasoning_receipt = reasoning_effort_receipt_for_route(
710 effective_reasoning_tier,
711 effective_provider,
712 &turn_route.candidate.endpoint().base_url,
713 &turn_route.model,
714 );
715
716 if let Err(err) = engine_handle
717 .send(Op::SendMessage {
718 content: prepare.content.clone(),
719 mode: prepare.mode,
720 route: Box::new(turn_route),
721 compaction: Box::new(turn_compaction.clone()),
722 goal_objective: prepare.goal_objective.clone(),
723 goal_token_budget: prepare.goal_token_budget,
724 goal_status: prepare.goal_status,
725 reasoning_effort: effective_reasoning_effort,
726 reasoning_effort_auto: auto_controls_reasoning,
727 auto_model: prepare.auto_model,
728 allow_shell: prepare.allow_shell,
729 trust_mode: prepare.trust_mode,
730 auto_approve: prepare.auto_approve,
731 approval_mode: prepare.approval_mode,
732 translation_enabled: prepare.translation_enabled,
733 allowed_tools: prepare.allowed_tools.clone(),
734 dynamic_tools: Vec::new(),
735 hook_executor: prepare.hook_executor.clone(),
736 verbosity: prepare.verbosity.clone(),
737 provenance: prepare.provenance,
738 })
739 .await
740 {
741 return build_dispatch_error_closure(prepare, recovery, err.to_string());
742 }
743
744 build_dispatch_success_closure(
745 prepare,
746 UserDispatchOutcome {
747 turn_compaction,
748 effective_provider,
749 effective_model,
750 effective_provider_identity,
751 effective_provider_label,
752 effective_reasoning_effort: effective_reasoning_receipt,
753 auto_selection,
754 },
755 )
756 }
757
758 pub(crate) fn build_dispatch_success_closure(
759 prepare: UserDispatchPrepare,
760 outcome: UserDispatchOutcome,
761 ) -> crate::tui::app::DispatchApplyFn {
762 Box::new(
763 move |app: &mut App, engine_handle: &EngineHandle, config: &Config| -> anyhow::Result<()> {
764 app.dispatch_in_flight = false;
765 prepare.paused_dispatch.apply(app, engine_handle);
766
767 let dispatch_started_at = Instant::now();
768 app.is_loading = true;
769 app.dispatch_started_at = Some(dispatch_started_at);
770 app.runtime_turn_status = None;
771 // last_send_at was already anchored in the sync prepare phase so
772 // the tail-flash starts together with the visible user cell.
773 app.last_submitted_prompt = Some(prepare.message.display.clone());
774 app.clear_receipt();
775 app.tool_evidence.clear();
776
777 app.system_prompt = Some(build_app_system_prompt_with_goal(
778 app,
779 config,
780 app.hunt.quarry.as_deref(),
781 ));
782 // History and api_messages were already appended in the sync prepare
783 // phase; record references now that the turn is accepted.
784 app.record_context_references(
785 prepare.history_cell,
786 prepare.message_index,
787 prepare.references,
788 );
789 app.scroll_to_bottom();
790
791 app.last_effective_reasoning_effort = Some(outcome.effective_reasoning_effort);
792 if prepare.auto_model {
793 app.last_effective_model = Some(outcome.effective_model.clone());
794 app.last_effective_provider = Some(outcome.effective_provider);
795 app.last_effective_provider_identity =
796 Some(outcome.effective_provider_identity.clone());
797 if let Some(selection) = outcome.auto_selection.as_ref() {
798 app.last_auto_route_receipt = selection.receipt.clone();
799 let status = app
800 .tr(MessageId::AutoRouteSelectedToast)
801 .replace("{provider}", &outcome.effective_provider_label)
802 .replace("{model}", &outcome.effective_model)
803 .replace("{source}", selection.source.label());
804 app.push_status_toast(status, StatusToastLevel::Info, Some(6_000));
805 }
806 } else {
807 app.last_effective_model = None;
808 app.last_effective_provider = None;
809 app.last_effective_provider_identity = None;
810 app.last_auto_route_receipt = None;
811 }
812 app.pending_auto_route_receipt = outcome
813 .auto_selection
814 .as_ref()
815 .and_then(|selection| selection.receipt.clone());
816 app.pending_turn_route = Some((
817 outcome.effective_provider,
818 outcome.effective_model,
819 prepare.auto_model,
820 ));
821
822 maybe_warn_context_pressure_for_config(app, &outcome.turn_compaction);
823 app.session.last_prompt_tokens = None;
824 app.session.last_completion_tokens = None;
825 app.session.last_output_throughput = None;
826 app.session.last_prompt_cache_hit_tokens = None;
827 app.session.last_prompt_cache_miss_tokens = None;
828 app.session.last_reasoning_replay_tokens = None;
829
830 if let Ok(manager) = SessionManager::default_location()
831 && let Ok(session) = build_session_snapshot(app, &manager)
832 {
833 if app.current_session_id.is_none() {
834 app.current_session_id = Some(session.metadata.id.clone());
835 }
836 if let Err(err) = persist_with_pending_work_boundary(
837 app,
838 PersistRequest::SaveCheckpoint { session },
839 ) {
840 app.status_message = Some(format!(
841 "Work update is pending: turn checkpoint could not be queued ({err})"
842 ));
843 }
844 }
845
846 Ok(())
847 },
848 )
849 }
850
851 pub(crate) fn build_dispatch_error_closure(
852 prepare: UserDispatchPrepare,
853 recovery: DispatchRecovery,
854 error: String,
855 ) -> crate::tui::app::DispatchApplyFn {
856 Box::new(
857 move |app: &mut App,
858 _engine_handle: &EngineHandle,
859 _config: &Config|
860 -> anyhow::Result<()> {
861 app.dispatch_in_flight = false;
862 // Roll back the optimistic sync prepare mutations.
863 app.is_loading = prepare.snapshot.is_loading;
864 app.runtime_turn_status = prepare.snapshot.runtime_turn_status.clone();
865 app.receipt_text = prepare.snapshot.receipt_text.clone();
866 app.receipt_started_at = prepare.snapshot.receipt_started_at;
867 app.tool_evidence = prepare.snapshot.tool_evidence.clone();
868 app.history.truncate(prepare.snapshot.history_len);
869 app.history_revisions
870 .truncate(prepare.snapshot.history_revisions_len);
871 app.history_version = prepare.snapshot.history_version;
872 app.next_history_revision = prepare.snapshot.next_history_revision;
873 app.api_messages.truncate(prepare.snapshot.api_messages_len);
874 app.last_send_at = prepare.snapshot.last_send_at;
875 app.needs_redraw = true;
876
877 match recovery {
878 DispatchRecovery::Immediate => {
879 restore_failed_immediate_submit(
880 app,
881 prepare.message,
882 &anyhow::Error::msg(error.clone()),
883 );
884 }
885 DispatchRecovery::Queued { restore_index } => {
886 restore_queued_message(app, restore_index, prepare.message);
887 app.status_message = Some(
888 app.tr(MessageId::DispatchFailedQueued)
889 .replace("{error}", &error)
890 .replace("{count}", &app.queued_message_count().to_string()),
891 );
892 }
893 DispatchRecovery::Draft => {
894 restore_queued_or_draft_message(app, DispatchRecovery::Draft, prepare.message);
895 app.status_message = Some(format!(
896 "Message dispatch failed ({error}); queued draft restored"
897 ));
898 }
899 DispatchRecovery::Initial => {
900 let initial_error = app
901 .tr(MessageId::DispatchFailedInitial)
902 .replace("{error}", &error);
903 restore_failed_immediate_submit(
904 app,
905 prepare.message,
906 &anyhow::Error::msg(initial_error),
907 );
908 }
909 }
910
911 Err(anyhow::Error::msg(error))
912 },
913 )
914 }
915
916 pub(crate) fn parse_queue_send_command(input: &str) -> Option<Result<usize, String>> {
917 let rest = strip_queue_command_prefix(input.trim())?;
918 let mut parts = rest.split_whitespace();
919 let action = parts.next()?;
920 if !action.eq_ignore_ascii_case("send") && !action.eq_ignore_ascii_case("now") {
921 return None;
922 }
923 let Some(raw_index) = parts.next() else {
924 return Some(Err("Usage: /queue send <n>".to_string()));
925 };
926 if parts.next().is_some() {
927 return Some(Err("Usage: /queue send <n>".to_string()));
928 }
929 let Ok(index) = raw_index.parse::<usize>() else {
930 return Some(Err("Index must be a positive number".to_string()));
931 };
932 if index == 0 {
933 return Some(Err("Index must be >= 1".to_string()));
934 }
935 Some(Ok(index - 1))
936 }
937
938 pub(crate) fn strip_queue_command_prefix(input: &str) -> Option<&str> {
939 for prefix in ["/queue", "/queued"] {
940 if let Some(rest) = input.strip_prefix(prefix)
941 && (rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace))
942 {
943 return Some(rest);
944 }
945 }
946 None
947 }
948
949 pub(crate) async fn steer_user_message(
950 app: &mut App,
951 config: &Config,
952 engine_handle: &EngineHandle,
953 mut message: QueuedMessage,
954 ) -> Result<bool> {
955 let stop_words = config.stop_words();
956 if is_stop_word(&message.display, &stop_words).is_some() {
957 engine_handle.cancel();
958 app.stopped_turn = true;
959 app.status_message = Some("Turn stopped. Tool calls blocked for this turn.".to_string());
960 return Ok(false);
961 }
962 app.stopped_turn = false;
963 // Same-turn steering is an engine-bound external-user path just like a
964 // fresh dispatch. Run the mutable gate exactly once on the blocking pool
965 // before pause state, history, references, or engine input are touched.
966 if app
967 .hooks
968 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
969 {
970 let context = app.base_hook_context().with_message(&message.display);
971 let strict_gates = app
972 .hooks
973 .matched_strict_gate_labels(crate::hooks::HookEvent::MessageSubmit, &context);
974 let hooks = app.hooks.clone();
975 let original_text = message.display.clone();
976 let outcome = match tokio::task::spawn_blocking(move || {
977 hooks.execute_message_submit_transform_for_dispatch(&context, &original_text)
978 })
979 .await
980 {
981 Ok(outcome) => outcome,
982 Err(error) => {
983 tracing::error!(target: "hooks", %error, "steer message_submit executor task was lost");
984 lost_message_submit_outcome(&strict_gates)
985 }
986 };
987 if !apply_message_submit_outcome(app, &mut message, outcome) {
988 return Ok(false);
989 }
990 }
991
992 let paused_snapshot = snapshot_steer_paused_state(app);
993 let paused_dispatch = plan_paused_command_message(app, &message.display);
994 let paused_note = paused_dispatch.note().map(str::to_string);
995 paused_dispatch.apply(app, engine_handle);
996 let cwd = std::env::current_dir().ok();
997 // Same single-submit cache as the other send path — see #4067 follow-up.
998 let mut git_cache = crate::tui::git_mention::GitMentionCache::default();
999 let completion_index = app.composer.mention_discovery.fuzzy_candidates(
1000 &app.workspace,
1001 &app.composer.mention_cwd,
1002 app.mention_walk_depth,
1003 app.workspace_follow_symlinks,
1004 );
1005 let references = crate::tui::file_mention::context_references_from_input_cached(
1006 &message.display,
1007 &app.workspace,
1008 cwd.clone(),
1009 &mut git_cache,
1010 completion_index,
1011 );
1012 let mut content = queued_message_content_for_app(app, &message, cwd, &mut git_cache)?;
1013 if let Some(note) = paused_note.as_deref() {
1014 content.push_str(note);
1015 }
1016 let message_index = app.api_messages.len();
1017
1018 // A foreground shell blocks the turn loop that consumes steer input.
1019 // Ask the shared shell manager to detach it before enqueueing the steer so
1020 // the loop can leave the foreground wait and process this message (#4930).
1021 if active_foreground_shell_running(app)
1022 && let Err(err) = request_active_foreground_shell_background(app)
1023 {
1024 restore_steer_paused_state(app, &paused_snapshot);
1025 engine_handle.set_paused(paused_snapshot.paused);
1026 return Err(err.context("could not move foreground shell to /jobs before steering"));
1027 }
1028
1029 if let Err(err) = engine_handle.steer(content.clone()).await {
1030 restore_steer_paused_state(app, &paused_snapshot);
1031 engine_handle.set_paused(paused_snapshot.paused);
1032 return Err(err);
1033 }
1034 app.last_submitted_prompt = Some(message.display.clone());
1035
1036 // Flush any streaming thinking/tool content into history before
1037 // inserting the steer message, so the steer appears after (below)
1038 // the content that chronologically preceded it.
1039 app.flush_active_cell();
1040
1041 // Mirror steer input in local transcript/session state.
1042 app.add_message(HistoryCell::User {
1043 content: format!("+ {}", message.display),
1044 });
1045 let history_cell = app.history.len().saturating_sub(1);
1046 app.record_context_references(history_cell, message_index, references);
1047 app.api_messages.push(Message {
1048 role: "user".to_string(),
1049 content: vec![ContentBlock::Text {
1050 text: content.clone(),
1051 cache_control: None,
1052 }],
1053 });
1054
1055 app.status_message = Some("Steering current turn...".to_string());
1056 Ok(true)
1057 }
1058
1059 pub(crate) fn snapshot_steer_paused_state(app: &App) -> SteerPausedSnapshot {
1060 SteerPausedSnapshot {
1061 paused: app.paused,
1062 pausable: app.pausable,
1063 paused_quarry: app.paused_quarry.clone(),
1064 quarry: app.hunt.quarry.clone(),
1065 tokens_used: app.hunt.tokens_used,
1066 time_used_seconds: app.hunt.time_used_seconds,
1067 continuation_count: app.hunt.continuation_count,
1068 }
1069 }
1070
1071 pub(crate) fn restore_steer_paused_state(app: &mut App, snapshot: &SteerPausedSnapshot) {
1072 app.paused = snapshot.paused;
1073 app.pausable = snapshot.pausable;
1074 app.paused_quarry = snapshot.paused_quarry.clone();
1075 app.hunt.quarry = snapshot.quarry.clone();
1076 app.hunt.tokens_used = snapshot.tokens_used;
1077 app.hunt.time_used_seconds = snapshot.time_used_seconds;
1078 app.hunt.continuation_count = snapshot.continuation_count;
1079 }
1080
1081 pub(crate) async fn attempt_steer_with_queue_fallback(
1082 app: &mut App,
1083 config: &Config,
1084 engine_handle: &EngineHandle,
1085 message: QueuedMessage,
1086 recovery: DispatchRecovery,
1087 ) {
1088 match steer_user_message(app, config, engine_handle, message.clone()).await {
1089 Ok(true) => {
1090 app.push_status_toast(
1091 "Steering into current turn",
1092 StatusToastLevel::Info,
1093 Some(1_500),
1094 );
1095 }
1096 Ok(false) => {
1097 restore_queued_or_draft_message(app, recovery, message);
1098 app.push_status_toast(
1099 "message_submit hook blocked the steer; original queue/draft restored",
1100 StatusToastLevel::Warning,
1101 Some(4_000),
1102 );
1103 }
1104 Err(err) => {
1105 restore_queued_or_draft_message(app, recovery, message);
1106 let status = format!(
1107 "Steer failed ({err}); {} queued follow-up(s) — /queue send <n>",
1108 app.queued_message_count()
1109 );
1110 app.status_message = Some(status.clone());
1111 app.push_status_toast(status, StatusToastLevel::Warning, Some(4_000));
1112 }
1113 }
1114 }
1115
1116 /// Park a draft on the queued-messages bucket for dispatch after TurnComplete.
1117 /// Unlike a steer, the message is NOT forwarded immediately — it waits for
1118 /// the current turn to finish, then dispatches as a normal user message.
1119 pub(crate) async fn queue_follow_up(app: &mut App, message: QueuedMessage) -> Result<()> {
1120 let display = message.display.clone();
1121 enqueue_offline_message(app, message);
1122 let toast = if app.mode == AppMode::Operate {
1123 format!(
1124 "Queued task: {display} ({} total) — dispatches next while workers continue; ↑ to edit",
1125 app.queued_message_count()
1126 )
1127 } else {
1128 format!(
1129 "Queued: {display} ({} total) — sends after current output; ↑ to edit",
1130 app.queued_message_count()
1131 )
1132 };
1133 app.status_message = Some(toast.clone());
1134 app.push_status_toast(toast, StatusToastLevel::Info, Some(3_000));
1135 Ok(())
1136 }
1137
1138 pub(crate) async fn dispatch_composer_message(
1139 app: &mut App,
1140 config: &Config,
1141 engine_handle: &EngineHandle,
1142 message: QueuedMessage,
1143 recovery: DispatchRecovery,
1144 action: ComposerSubmitAction,
1145 ) -> Result<()> {
1146 if app.remote_control.blocks_local_input() {
1147 app.input = message.display;
1148 app.cursor_position = app.input.chars().count();
1149 let status =
1150 "Web remote control owns prompts. Use /rc stop to return input to this terminal."
1151 .to_string();
1152 app.status_message = Some(status.clone());
1153 app.push_status_toast(status, StatusToastLevel::Warning, Some(6_000));
1154 return Ok(());
1155 }
1156 let disposition = match action {
1157 ComposerSubmitAction::Submit(disposition) => disposition,
1158 ComposerSubmitAction::SendQueuedNow | ComposerSubmitAction::Noop => {
1159 // The caller extracted a non-empty input, so these can only arise
1160 // if state changed between key resolution and dispatch. Queueing
1161 // is lossless and preserves ordering in that narrow race.
1162 SubmitDisposition::Queue
1163 }
1164 };
1165 match disposition {
1166 SubmitDisposition::Immediate => {
1167 let _ =
1168 dispatch_user_message_with_recovery(app, config, engine_handle, message, recovery)
1169 .await;
1170 Ok(())
1171 }
1172 SubmitDisposition::Queue => {
1173 let count = app.queued_message_count().saturating_add(1);
1174 enqueue_offline_message(app, message);
1175 let (status, toast) = if app.offline_mode {
1176 (
1177 format!("Offline: {count} queued follow-up(s) — ↑ edit last, /queue send <n>"),
1178 format!("Offline: queued follow-up ({count} total)"),
1179 )
1180 } else if app.mode == AppMode::Operate {
1181 (
1182 format!(
1183 "{count} queued task(s) — dispatches next while workers continue; ↑ edit last, /queue send <n>"
1184 ),
1185 format!("Queued task ({count} total) — dispatches next"),
1186 )
1187 } else {
1188 (
1189 format!(
1190 "{count} queued follow-up(s) — sends after current output; ↑ edit last, /queue send <n>"
1191 ),
1192 format!("Queued follow-up ({count} total) — sends after current output"),
1193 )
1194 };
1195 app.status_message = Some(status);
1196 app.push_status_toast(toast, StatusToastLevel::Info, Some(3_000));
1197 Ok(())
1198 }
1199 SubmitDisposition::Steer => {
1200 attempt_steer_with_queue_fallback(app, config, engine_handle, message, recovery).await;
1201 Ok(())
1202 }
1203 SubmitDisposition::QueueFollowUp => queue_follow_up(app, message).await,
1204 }
1205 }
1206
1207 #[cfg(test)]
1208 pub(crate) async fn submit_or_steer_message(
1209 app: &mut App,
1210 config: &Config,
1211 engine_handle: &EngineHandle,
1212 message: QueuedMessage,
1213 recovery: DispatchRecovery,
1214 ) -> Result<()> {
1215 let action = ComposerSubmitAction::Submit(app.decide_submit_disposition());
1216 dispatch_composer_message(app, config, engine_handle, message, recovery, action).await
1217 }
1218
1219 /// Drain `app.pending_steers` into a single `QueuedMessage` ready for
1220 /// `dispatch_user_message`. Returns `None` if the queue was empty (caller
1221 /// then falls back to `app.queued_messages`). Skill instruction is taken
1222 /// from the first message that supplies one — multiple steers shouldn't
1223 /// double-up the system framing.
1224 pub(crate) fn merge_pending_steers(app: &mut App) -> Option<QueuedMessage> {
1225 let drained = app.drain_pending_steers();
1226 if drained.is_empty() {
1227 return None;
1228 }
1229 if drained.len() == 1 {
1230 return drained.into_iter().next();
1231 }
1232 let mut skill_instruction: Option<String> = None;
1233 let mut skill_provenance = None;
1234 let mut bodies: Vec<String> = Vec::with_capacity(drained.len());
1235 for msg in drained {
1236 if skill_instruction.is_none() {
1237 skill_instruction = msg.skill_instruction;
1238 skill_provenance = msg.skill_provenance;
1239 }
1240 bodies.push(msg.display);
1241 }
1242 Some(
1243 QueuedMessage::new(bodies.join("\n\n"), skill_instruction)
1244 .with_skill_provenance(skill_provenance),
1245 )
1246 }
1247
1247 lines RUST