返回 CodeWhale
apply.rs
根目录 / crates / tui / src / tui / ui / apply.rs
1 //! `apply_*` helpers: committing an already-resolved choice to `App`, the
2 //! engine, and persisted settings.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 /// Apply the normal spawn status first, then submit its observer event.
9 /// Submission diagnostics go to the independent toast queue, so they remain
10 /// visible without replacing the agent's authoritative lifecycle status.
11 pub(crate) fn apply_agent_spawned_status_and_observer(
12 app: &mut App,
13 agent_id: &str,
14 prompt: &str,
15 prompt_summary: &str,
16 ) {
17 let label = app.ensure_agent_label(agent_id);
18 codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::SubagentSpawn);
19 app.status_message = Some(format!("{label} starting: {prompt_summary}"));
20 if let Err(error) =
21 execute_subagent_observer_hook(app, HookEvent::SubagentSpawn, agent_id, "prompt", prompt)
22 {
23 surface_observer_hook_submission_failure(app, error);
24 }
25 }
26
27 /// Completion counterpart to [`apply_agent_spawned_status_and_observer`].
28 pub(crate) fn apply_agent_complete_status_and_observer(
29 app: &mut App,
30 agent_id: &str,
31 result: &str,
32 terminal_verb: &str,
33 ) {
34 let label = app.agent_display_label(agent_id);
35 app.status_message = Some(format!(
36 "{label} {terminal_verb}: {}",
37 bound_agent_activity_text(result)
38 ));
39 if let Err(error) =
40 execute_subagent_observer_hook(app, HookEvent::SubagentComplete, agent_id, "result", result)
41 {
42 surface_observer_hook_submission_failure(app, error);
43 }
44 }
45
46 pub(crate) fn apply_coordination_detail_projection(
47 app: &mut App,
48 projection: crate::tools::subagent::CoordinationDetailProjection,
49 ) {
50 // §2.6: when this process does not own the workspace coordination flock,
51 // say so on the sticky status strip. A silent "running (543s)" row on a
52 // settled turn is a lie; surface the lock loss the same way we surface
53 // other session hazards.
54 //
55 // Exception: a same-process handover. A model/provider switch spawns the
56 // new engine before the old engine's manager has dropped the flock, and
57 // flock treats the second fd in this same process as a conflict. That
58 // state self-heals on the next projection retry (#5036), and a 30-second
59 // warning blaming "another Codewhale process" would be false (owner
60 // report, 2026-08-04) — so it stays off the sticky strip.
61 if !projection.process_lock_held {
62 let note = projection
63 .process_lock_note
64 .as_deref()
65 .unwrap_or("another Codewhale process owns delegated coordination for this workspace");
66 let same_process_handover =
67 note.contains(crate::tools::subagent::COORDINATION_SAME_PROCESS_HANDOVER);
68 // The strip is one row. The old copy opened with the diagnosis
69 // ("Delegated coordination unavailable — ") and buried the cause
70 // behind a `{note}` carrying a pid, an absolute workspace path, and an
71 // errno, so a truncated strip showed `Delegated coordination
72 // unavailable — an…` and taught the user nothing. Lead with the fact
73 // that explains it — a second session is open here — and leave the pid
74 // and path to the coordination detail view, which already renders
75 // `process_lock_note` in full.
76 let message = if note.contains(crate::tools::subagent::COORDINATION_LOCK_TIMEOUT_MARKER) {
77 "Timed out claiming delegated coordination for this workspace — job rows still settle locally.".to_string()
78 } else {
79 "Another CodeWhale session in this workspace owns delegated coordination — job rows still settle locally.".to_string()
80 };
81 // Demoted from sticky 30s to transient 5s — two sessions in same workspace
82 // should not feel broken; job rows still settle locally. The detail view
83 // still shows the full pid/path via `process_lock_note`.
84 let already = app
85 .status_toasts
86 .iter()
87 .any(|toast| toast.text.contains("delegated coordination"));
88 if !already && !same_process_handover {
89 app.push_status_toast(
90 message,
91 crate::tui::app::StatusToastLevel::Info,
92 Some(5_000),
93 );
94 }
95 }
96 app.coordination_detail = Some(projection);
97 }
98
99 pub(crate) fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
100 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Pinned);
101 }
102
103 pub(crate) fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) {
104 // Ctrl+Alt+0 toggles the rail off and back to the default top
105 // placement. Plain Alt+0 is unbound: it used to select the retired
106 // auto-collapse mode.
107 if modifiers.contains(KeyModifiers::CONTROL) {
108 if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
109 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
110 app.status_message = Some("Rail: top placement".to_string());
111 } else {
112 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
113 app.status_message = Some("Rail is off".to_string());
114 }
115 app.needs_redraw = true;
116 }
117 }
118
119 pub(crate) fn apply_picker_session_rename_to_active_app(
120 app: &mut App,
121 metadata: crate::session_manager::SessionMetadata,
122 ) -> bool {
123 if app.current_session_id.as_deref() != Some(metadata.id.as_str()) {
124 return false;
125 }
126 app.session_title = Some(metadata.title.clone());
127 app.current_session_metadata = Some(metadata);
128 true
129 }
130
131 /// Translate an `EngineEvent::Error` into UI state updates.
132 ///
133 /// The engine's `recoverable` flag (mirrored on `ErrorEnvelope`) decides
134 /// whether the session flips into offline mode: stream stalls, chunk
135 /// timeouts, transient network errors, and rate-limit/server hiccups arrive
136 /// recoverable and must NOT flip into offline. Hard failures (auth, billing,
137 /// invalid request) arrive non-recoverable; those flip offline so subsequent
138 /// messages get queued instead of silently lost mid-flight.
139 ///
140 /// `severity` drives transcript color: red for `Error`/`Critical`, amber for
141 /// `Warning`, dim for `Info`.
142 pub(crate) fn apply_engine_error_to_app(
143 app: &mut App,
144 envelope: crate::error_taxonomy::ErrorEnvelope,
145 ) {
146 let recoverable = envelope.recoverable;
147 let message = envelope.message.clone();
148 let severity = envelope.severity;
149 let turn_was_in_progress =
150 app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"));
151 streaming_thinking::finalize_current(app);
152 if turn_was_in_progress {
153 app.finalize_streaming_assistant_as_interrupted();
154 app.finalize_active_cell_as_interrupted();
155 app.runtime_turn_status = Some("failed".to_string());
156 }
157 app.streaming_state.reset();
158 app.streaming_message_index = None;
159 app.streaming_thinking_active_entry = None;
160
161 // #455 (observer-only): fire `on_error` hooks so operators can
162 // page on auth / billing / invalid-request failures without
163 // tailing the audit log. Read-only — the hook can react but not
164 // suppress the error from reaching the transcript. Fast-path
165 // skip when no hooks configured.
166 if app
167 .hooks
168 .has_hooks_for_event(crate::hooks::HookEvent::OnError)
169 {
170 let context = app.base_hook_context().with_error(&message);
171 if let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context) {
172 surface_observer_hook_submission_failure(app, error);
173 }
174 }
175
176 app.add_message(HistoryCell::Error {
177 message: message.clone(),
178 severity,
179 });
180 app.is_loading = false;
181 app.dispatch_started_at = None;
182 app.turn_error_posted = true;
183 if matches!(
184 envelope.category,
185 crate::error_taxonomy::ErrorCategory::Authentication
186 ) && app.api_key_env_only
187 {
188 app.offline_mode = true;
189 app.onboarding_needs_api_key = true;
190 app.onboarding = OnboardingState::Provider;
191 let provider = app.api_provider;
192 let config_path = match crate::config::resolve_load_config_path(app.config_path.clone()) {
193 Ok(Some(path)) => path.display().to_string(),
194 Ok(None) => "~/.codewhale/config.toml".to_string(),
195 Err(error) => error.to_string(),
196 };
197 app.status_message = Some(
198 tr(app.ui_locale, MessageId::OnboardApiKeyRejectedEnv)
199 .replace("{provider}", provider.as_str())
200 .replace("{env}", &provider.env_vars_label())
201 .replace("{path}", &config_path),
202 );
203 return;
204 }
205 if recoverable
206 && matches!(
207 envelope.category,
208 crate::error_taxonomy::ErrorCategory::Network
209 | crate::error_taxonomy::ErrorCategory::RateLimit
210 | crate::error_taxonomy::ErrorCategory::Timeout
211 )
212 && app.advance_fallback(message.clone()).is_some()
213 {
214 let position = app.fallback_chain_position().unwrap_or(0);
215 let total = app.fallback_chain_len();
216 app.status_message = Some(format!(
217 "Switched to {} (fallback {position}/{}) after recoverable provider error.",
218 app.api_provider.as_str(),
219 total.saturating_sub(1)
220 ));
221 return;
222 }
223 if !recoverable {
224 app.offline_mode = true;
225 }
226 // Error is already in the transcript as HistoryCell::Error above;
227 // don't emit a redundant status_message that would become a sticky
228 // toast in the footer — that duplicates the transcript entry.
229 }
230
231 /// Apply the gate result on the event loop. Returns `true` when dispatch may
232 /// continue; a denial leaves the original message out of history/model input.
233 pub(crate) fn apply_message_submit_outcome(
234 app: &mut App,
235 message: &mut QueuedMessage,
236 outcome: crate::hooks::MessageSubmitOutcome,
237 ) -> bool {
238 if let Some(warning) = outcome.warning() {
239 app.status_message = Some(warning.to_string());
240 }
241 match outcome {
242 crate::hooks::MessageSubmitOutcome::Unchanged { .. } => true,
243 crate::hooks::MessageSubmitOutcome::Replaced { text, .. } => {
244 message.display = text;
245 true
246 }
247 crate::hooks::MessageSubmitOutcome::Blocked { reason } => {
248 app.status_message = Some(reason);
249 false
250 }
251 }
252 }
253
254 pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot) -> bool {
255 // An explicit engine-side clear is represented by the one canonical empty
256 // state emitted by GoalState::snapshot. Require both fields so a malformed
257 // objective-less Active/Blocked update cannot erase valid visible state.
258 if snapshot.objective.is_none() && snapshot.status.trim() == "none" {
259 let changed = app.hunt.quarry.is_some()
260 || app.hunt.token_budget.is_some()
261 || app.hunt.tokens_used != 0
262 || app.hunt.time_used_seconds != 0
263 || app.hunt.continuation_count != 0
264 || app.hunt.started_at.is_some()
265 || app.hunt.finished_at.is_some()
266 || app.hunt.verdict != HuntVerdict::default();
267 app.hunt = crate::tui::app::HuntState::default();
268 return changed;
269 }
270
271 let Some(objective) = snapshot
272 .objective
273 .as_deref()
274 .map(str::trim)
275 .filter(|objective| !objective.is_empty())
276 else {
277 tracing::warn!(
278 "ignoring objective-less runtime goal snapshot with non-clear status: {}",
279 snapshot.status
280 );
281 return false;
282 };
283 let Some(status) = goal_status_from_snapshot(snapshot) else {
284 tracing::warn!("ignoring unknown runtime goal status: {}", snapshot.status);
285 return false;
286 };
287 let verdict = HuntVerdict::from_goal_status(status);
288 let objective_changed = app.hunt.quarry.as_deref() != Some(objective);
289 let changed = objective_changed
290 || app.hunt.token_budget != snapshot.token_budget
291 || app.hunt.tokens_used != snapshot.tokens_used
292 || app.hunt.time_used_seconds != snapshot.time_used_seconds
293 || app.hunt.continuation_count != snapshot.continuation_count
294 || app.hunt.pause_reason != snapshot.pause_reason
295 || app.hunt.verdict != verdict;
296 if !changed {
297 return false;
298 }
299
300 app.hunt.quarry = Some(objective.to_string());
301 app.hunt.token_budget = snapshot.token_budget;
302 app.hunt.tokens_used = snapshot.tokens_used;
303 app.hunt.time_used_seconds = snapshot.time_used_seconds;
304 app.hunt.continuation_count = snapshot.continuation_count;
305 app.hunt.pause_reason = snapshot.pause_reason;
306 app.hunt.verdict = verdict;
307 if objective_changed || app.hunt.started_at.is_none() {
308 app.hunt.started_at = Some(Instant::now());
309 }
310 // Freeze the elapsed timer the first time a goal leaves the active state.
311 // Paused (Wounded) goals freeze too — usage snapshots keep arriving while
312 // paused, and clearing here would silently un-freeze a timer the user just
313 // paused (matching close_hunt, which records the pause instant). Only an
314 // explicit resume back to Hunting re-arms the timer.
315 match verdict {
316 HuntVerdict::Hunted | HuntVerdict::Escaped | HuntVerdict::Wounded => {
317 if app.hunt.finished_at.is_none() {
318 app.hunt.finished_at = Some(Instant::now());
319 }
320 }
321 HuntVerdict::Hunting => app.hunt.finished_at = None,
322 }
323 true
324 }
325
326 /// Apply an explicit mode selection from a user shortcut (Alt+A/P/Y).
327 ///
328 /// Uses `select_mode`, not `set_mode`, so an explicitly chosen mode is also the
329 /// startup default next launch — matching the Tab cycle and hotbar paths.
330 pub(crate) async fn apply_mode_update(
331 app: &mut App,
332 engine_handle: &EngineHandle,
333 mode: AppMode,
334 ) -> bool {
335 let outcome = app.select_mode(mode);
336 app.report_mode_selection(mode, outcome);
337 if outcome.changed_live_state() {
338 sync_mode_update(app, engine_handle).await;
339 true
340 } else {
341 false
342 }
343 }
344
345 pub(crate) async fn apply_model_and_compaction_update(
346 engine_handle: &EngineHandle,
347 compaction: crate::compaction::CompactionConfig,
348 mode: AppMode,
349 route_limits: Option<codewhale_config::route::RouteLimits>,
350 ) {
351 let _ = engine_handle
352 .send(Op::SetModel {
353 model: compaction.model.clone(),
354 mode,
355 route_limits,
356 })
357 .await;
358 let _ = engine_handle
359 .send(Op::SetCompaction { config: compaction })
360 .await;
361 }
362
363 /// Apply the choice made in the `/model` picker (#39): mutate App state so
364 /// the next turn uses the new model/effort, push the change to the running
365 /// engine via `Op::SetModel`/`Op::SetCompaction`, and surface a one-line
366 /// status describing what changed. Startup persistence is intentionally owned
367 /// by the picker's explicit Shift+D action in the view-event handler.
368 // The model/effort transition needs both the previous and next model+effort
369 // plus the engine, app, and config handles; bundling them into a struct here
370 // would only obscure a straightforward orchestration step.
371 #[allow(clippy::too_many_arguments)]
372 pub(crate) async fn apply_model_picker_choice(
373 app: &mut App,
374 engine_handle: &mut EngineHandle,
375 config: &mut Config,
376 model: String,
377 target_provider: Option<ApiProvider>,
378 target_provider_id: Option<String>,
379 effort: crate::tui::app::ReasoningEffort,
380 previous_model: String,
381 previous_effort: crate::tui::app::ReasoningEffort,
382 save_as_startup_default: bool,
383 ) {
384 if app.reject_setting_change_while_busy(
385 crate::localization::MessageId::SettingSubjectModelAndThinking,
386 ) {
387 note_startup_default_not_saved(app, save_as_startup_default);
388 return;
389 }
390 let target_provider = target_provider.unwrap_or(app.api_provider);
391 let target_identity = if target_provider == ApiProvider::Custom {
392 target_provider_id.unwrap_or_else(|| config.provider_identity_for(target_provider))
393 } else {
394 target_provider.as_str().to_string()
395 };
396 let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
397 let preserve_auto_effort =
398 app.reasoning_effort_preference.is_some() || effort != previous_effort;
399 if target_provider != app.api_provider
400 || target_identity != app.provider_identity_for_persistence()
401 {
402 config.provider = Some(target_identity.clone());
403 switch_provider(
404 app,
405 engine_handle,
406 config,
407 target_provider,
408 (!model_is_auto).then_some(model.clone()),
409 )
410 .await;
411 if app.api_provider != target_provider
412 || app.provider_identity_for_persistence() != target_identity
413 {
414 // The switch was refused (missing credentials, bad route). The
415 // live route is still the old one, so persisting it as the startup
416 // default would silently pin the route the user just tried to leave.
417 note_startup_default_not_saved(app, save_as_startup_default);
418 return;
419 }
420 if !model_is_auto {
421 apply_picker_effort_choice(app, engine_handle, effort, previous_effort).await;
422 if save_as_startup_default {
423 app.status_message = Some(app.save_live_route_as_startup_default());
424 }
425 return;
426 }
427 }
428
429 let model_changed = model != previous_model || app.auto_model != model_is_auto;
430 let mut resolved_model = model.clone();
431 let mut route_base_url = config.deepseek_base_url();
432 if !model_is_auto {
433 let saved_provider_model = config
434 .provider_config_for(app.api_provider)
435 .and_then(|provider| provider.model.as_deref());
436 match crate::route_runtime::resolve_route_candidate_with_context_metadata(
437 app.api_provider,
438 Some(&model),
439 saved_provider_model,
440 Some(config.deepseek_base_url()),
441 config.context_window_for_provider_config(app.api_provider),
442 None,
443 ) {
444 Ok(resolution) => {
445 resolved_model = resolution.candidate.wire_model_id().as_str().to_string();
446 route_base_url = resolution.candidate.endpoint().base_url.clone();
447 if model_changed {
448 app.set_active_context_window_override(
449 config.context_window_for_provider_config(app.api_provider),
450 );
451 app.set_active_route_resolution(
452 route_base_url.clone(),
453 resolution.candidate.limits(),
454 resolution.context_window.source,
455 );
456 }
457 }
458 Err(reason) => {
459 app.status_message = Some(reason);
460 note_startup_default_not_saved(app, save_as_startup_default);
461 return;
462 }
463 }
464 } else if model_changed {
465 app.set_active_context_window_override(
466 config.context_window_for_provider_config(app.api_provider),
467 );
468 app.active_route_limits = app.context_window_override_limits();
469 app.active_route_base_url = route_base_url.clone();
470 app.active_context_window_source = if app.active_context_window_override.is_some() {
471 crate::route_runtime::ContextWindowSource::Configured
472 } else {
473 crate::route_runtime::ContextWindowSource::Fallback
474 };
475 }
476
477 let effective_effort = if model_is_auto {
478 effort
479 } else {
480 effort.normalize_for_route(app.api_provider, &route_base_url, &resolved_model)
481 };
482 let effort_changed = effort != previous_effort;
483
484 if model_changed {
485 app.set_model_selection(resolved_model.clone());
486 let provider_identity = app.provider_identity_for_persistence().to_string();
487 app.provider_models
488 .insert(provider_identity.clone(), resolved_model.clone());
489 app.enable_provider_model(&provider_identity, &resolved_model);
490 app.clear_model_scoped_telemetry();
491 }
492 let preference_changed = if model_is_auto && !preserve_auto_effort {
493 app.reasoning_effort_preference.take().is_some()
494 } else {
495 let changed = app.reasoning_effort_preference != Some(effort);
496 app.reasoning_effort_preference = Some(effort);
497 changed
498 };
499 let live_effort_changed = effective_effort != app.reasoning_effort;
500 if !model_is_auto || preserve_auto_effort {
501 app.reasoning_effort = effective_effort;
502 } else {
503 app.reasoning_effort = ReasoningEffort::Auto;
504 }
505 if live_effort_changed || preference_changed {
506 app.invalidate_route_receipts_for_reasoning_change();
507 }
508 if model_changed || live_effort_changed || preference_changed {
509 app.update_model_compaction_budget();
510 }
511
512 // A model pick is session-local by default. Keep the exact live route in
513 // memory and offer an explicit save decision; only Shift+D in the picker
514 // writes a startup default.
515 let route_provider = app.provider_identity_for_persistence().to_string();
516 app.note_session_route_change(&route_provider, &resolved_model);
517
518 if model_changed {
519 apply_model_and_compaction_update(
520 engine_handle,
521 app.compaction_config(),
522 app.mode,
523 app.active_route_limits,
524 )
525 .await;
526 }
527
528 let model_summary = if model_is_auto {
529 "auto (per-turn model)".to_string()
530 } else {
531 resolved_model.clone()
532 };
533 let previous_effort_summary = previous_effort.display_label_for_provider(app.api_provider);
534 let applied_effort = app.reasoning_effort;
535 let effort_summary = if applied_effort == ReasoningEffort::Auto {
536 "auto (per-turn thinking)".to_string()
537 } else {
538 applied_effort
539 .display_label_for_provider(app.api_provider)
540 .to_string()
541 };
542
543 let summary = match (model_changed, effort_changed) {
544 (true, true) => format!(
545 "Model: {previous_model} → {model_summary} · thinking: {previous_effort_summary} → {effort_summary}"
546 ),
547 (true, false) => {
548 format!("Model: {previous_model} → {model_summary} · thinking {effort_summary}")
549 }
550 (false, true) => format!(
551 "Thinking: {previous_effort_summary} → {effort_summary} · model {model_summary}"
552 ),
553 (false, false) => {
554 format!("Model unchanged: {model_summary} · thinking {effort_summary}")
555 }
556 };
557 app.status_message = Some(summary);
558 // Setup progress records that a concrete route was selected successfully;
559 // it is a local receipt, not a claim that the route became the default.
560 if model_changed || !model_is_auto {
561 record_provider_model_setup_progress(app, config);
562 }
563 if save_as_startup_default {
564 app.status_message = Some(app.save_live_route_as_startup_default());
565 }
566 }
567
568 pub(crate) async fn apply_picker_effort_choice(
569 app: &mut App,
570 engine_handle: &EngineHandle,
571 effort: ReasoningEffort,
572 previous_effort: ReasoningEffort,
573 ) {
574 if app.reject_setting_change_while_busy(crate::localization::MessageId::SettingSubjectThinking)
575 {
576 return;
577 }
578 let effective_effort = if app.auto_model {
579 effort
580 } else {
581 effort.normalize_for_route(app.api_provider, &app.active_route_base_url, &app.model)
582 };
583 let live_changed = effective_effort != app.reasoning_effort;
584 let preference_changed = app.reasoning_effort_preference != Some(effort);
585 let selection_changed = effort != previous_effort || live_changed;
586
587 if live_changed || preference_changed {
588 app.reasoning_effort = effective_effort;
589 app.reasoning_effort_preference = Some(effort);
590 }
591 if selection_changed {
592 app.invalidate_route_receipts_for_reasoning_change();
593 app.update_model_compaction_budget();
594 }
595
596 let persist_warning = app
597 .startup_defaults
598 .apply_blocking(
599 crate::tui::startup_defaults::StartupDefaults::reasoning_effort(effort.as_setting()),
600 )
601 .err()
602 .map(|err| format!(" (not persisted: {err})"));
603
604 if live_changed {
605 apply_model_and_compaction_update(
606 engine_handle,
607 app.compaction_config(),
608 app.mode,
609 app.active_route_limits,
610 )
611 .await;
612 }
613
614 let persisted = persist_warning.is_none();
615 let mut summary = if selection_changed {
616 format!(
617 "Thinking: {} → {} · model {}",
618 previous_effort.display_label_for_provider(app.api_provider),
619 effort.display_label_for_provider(app.api_provider),
620 app.model_display_label()
621 )
622 } else {
623 let mut summary = format!(
624 "Thinking unchanged: {} · model {}",
625 effort.display_label_for_provider(app.api_provider),
626 app.model_display_label()
627 );
628 if persisted {
629 summary.push_str(" · ");
630 summary.push_str(&app.tr(crate::localization::MessageId::SavedAsStartupDefault));
631 }
632 summary
633 };
634 if let Some(warning) = persist_warning {
635 summary.push_str(&warning);
636 }
637 app.status_message = Some(summary);
638 }
639
640 pub(crate) async fn apply_provider_fallback_switch(
641 app: &mut App,
642 engine_handle: &mut EngineHandle,
643 config: &mut Config,
644 rollback: ProviderFallbackRollback,
645 ) {
646 let ProviderFallbackRollback {
647 identity: previous_identity,
648 chain: previous_chain,
649 } = rollback;
650 let previous_provider = previous_identity.provider;
651 let target = app.api_provider;
652 let previous_model = app.model.clone();
653
654 let resolved_route = match resolve_runtime_route(config, target, None) {
655 Ok(route) => route,
656 Err(reason) => {
657 app.set_provider_identity_record(previous_identity.clone());
658 app.provider_chain = previous_chain.clone();
659 app.last_fallback_reason = Some(format!(
660 "Fallback provider {} route was rejected: {reason}",
661 target.as_str()
662 ));
663 app.status_message = Some(format!(
664 "Fallback provider {} rejected; provider remains {}.",
665 target.as_str(),
666 previous_provider.as_str()
667 ));
668 return;
669 }
670 };
671 let target_identity = resolved_route.identity.clone();
672 let resolved_endpoint = resolved_route.candidate.endpoint().base_url.clone();
673 let next_config = resolved_route.config;
674 let new_model = resolved_route.model;
675 let context_window_source = resolved_route.context_window.source;
676
677 if let Err(err) = DeepSeekClient::from_candidate(&next_config, &resolved_route.candidate) {
678 app.set_provider_identity_record(previous_identity);
679 app.provider_chain = previous_chain;
680 app.last_fallback_reason = Some(format!(
681 "Fallback provider {} was unavailable: {err}",
682 target.as_str()
683 ));
684 app.status_message = Some(format!(
685 "Fallback provider {} unavailable; provider remains {}.",
686 target.as_str(),
687 previous_provider.as_str()
688 ));
689 return;
690 }
691 *config = *next_config;
692 app.set_provider_identity_record(target_identity);
693 app.billing_presentation = crate::route_billing::for_route(config, target);
694
695 let new_base_url = resolved_endpoint;
696 let new_endpoint = display_base_url_host(&new_base_url);
697 let cache_scope_changed = previous_provider != target || previous_model != new_model;
698 app.model_ids_passthrough = config.model_ids_pass_through();
699 app.set_model_selection(new_model.clone());
700 app.apply_provider_switch_reasoning_effort(target, &new_base_url, None);
701 app.set_active_context_window_override(config.context_window_for_provider_config(target));
702 app.set_active_route_resolution(
703 new_base_url.clone(),
704 resolved_route.candidate.limits(),
705 context_window_source,
706 );
707 app.update_model_compaction_budget();
708 if cache_scope_changed {
709 app.clear_model_scoped_telemetry();
710 } else {
711 app.session.last_prompt_tokens = None;
712 app.session.last_completion_tokens = None;
713 app.session.last_output_throughput = None;
714 }
715
716 let _ = engine_handle.send(Op::Shutdown).await;
717 let engine_config = build_engine_config(app, config);
718 *engine_handle = spawn_tui_engine(engine_config, config);
719
720 if !app.api_messages.is_empty() {
721 let _ = engine_handle
722 .send(Op::SyncSession {
723 session_id: app.current_session_id.clone(),
724 messages: app.api_messages.clone(),
725 system_prompt: app.system_prompt.clone(),
726 system_prompt_override: false,
727 model: app.model.clone(),
728 workspace: app.workspace.clone(),
729 mode: app.mode,
730 })
731 .await;
732 }
733 let _ = engine_handle
734 .send(Op::SetCompaction {
735 config: app.compaction_config(),
736 })
737 .await;
738
739 app.add_message(HistoryCell::System {
740 content: format!(
741 "Provider fallback: {} -> {}\nModel: {} -> {}\nEndpoint: {}",
742 previous_provider.as_str(),
743 target.as_str(),
744 previous_model,
745 new_model,
746 new_endpoint
747 ),
748 });
749 app.status_message = Some(format!(
750 "Fallback provider: {} via {}",
751 target.as_str(),
752 new_endpoint
753 ));
754 }
755
756 pub(crate) async fn apply_command_result(
757 terminal: &mut AppTerminal,
758 app: &mut App,
759 engine_handle: &mut EngineHandle,
760 task_manager: &SharedTaskManager,
761 config: &mut Config,
762 #[cfg_attr(not(feature = "web"), allow(unused_variables))] web_config_session: &mut Option<
763 WebConfigSession,
764 >,
765 result: commands::CommandResult,
766 ) -> Result<bool> {
767 if let Some(msg) = result.message {
768 app.add_message(HistoryCell::System { content: msg });
769 }
770
771 if let Some(action) = result.action {
772 match action {
773 AppAction::Quit => {
774 let _ = engine_handle.send(Op::Shutdown).await;
775 return Ok(true);
776 }
777 AppAction::LoadSession(path) => {
778 let session: SavedSession = match std::fs::read_to_string(&path)
779 .map_err(|err| err.to_string())
780 .and_then(|raw| serde_json::from_str(&raw).map_err(|err| err.to_string()))
781 {
782 Ok(session) => session,
783 Err(err) => {
784 app.status_message = Some(format!(
785 "Failed to load session from {}: {err}",
786 path.display()
787 ));
788 return Ok(false);
789 }
790 };
791 let fresh_config =
792 match Config::load(app.config_path.clone(), app.config_profile.as_deref()) {
793 Ok(config) => config,
794 Err(err) => {
795 app.status_message = Some(format!(
796 "Failed to load live config for session restore: {err}"
797 ));
798 return Ok(false);
799 }
800 };
801 let respawn = match apply_loaded_session_config_snapshot(
802 app,
803 config,
804 &session,
805 fresh_config,
806 true,
807 ) {
808 Ok(outcome) => outcome,
809 Err(err) => {
810 app.status_message = Some(format!("Failed to restore session: {err}"));
811 return Ok(false);
812 }
813 };
814 sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
815 if respawn {
816 let _ = engine_handle.send(Op::Shutdown).await;
817 *engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
818 } else {
819 let _ = engine_handle
820 .send(Op::SetModel {
821 model: app.model.clone(),
822 mode: app.mode,
823 route_limits: app.active_route_limits,
824 })
825 .await;
826 }
827 let _ = engine_handle
828 .send(Op::SyncSession {
829 session_id: app.current_session_id.clone(),
830 messages: app.api_messages.clone(),
831 system_prompt: app.system_prompt.clone(),
832 system_prompt_override: false,
833 model: app.model.clone(),
834 workspace: app.workspace.clone(),
835 mode: app.mode,
836 })
837 .await;
838 let _ = engine_handle
839 .send(Op::SetCompaction {
840 config: app.compaction_config(),
841 })
842 .await;
843 let success_message = format!(
844 "Session loaded from {} (ID: {}, {} messages)",
845 path.display(),
846 crate::session_manager::truncate_id(&session.metadata.id),
847 session.metadata.message_count
848 );
849 app.add_message(HistoryCell::System {
850 content: success_message.clone(),
851 });
852 app.status_message = Some(success_message);
853 }
854 AppAction::SyncSession {
855 session_id,
856 messages,
857 system_prompt,
858 model,
859 workspace,
860 mode,
861 } => {
862 let mut session_id = session_id;
863 let is_full_reset = messages.is_empty() && system_prompt.is_none();
864 if is_full_reset && session_id.is_none() {
865 let new_session_id = uuid::Uuid::new_v4().to_string();
866 app.current_session_id = Some(new_session_id.clone());
867 session_id = Some(new_session_id);
868 }
869 let workspace_changed = task_manager.default_workspace().await != workspace;
870 if workspace_changed {
871 apply_workspace_runtime_state(app, config, workspace.clone());
872 sync_runtime_workspace_state(task_manager, workspace.clone()).await;
873 }
874 let provider_changed = config.api_provider() != app.api_provider
875 || config.provider_identity_for(config.api_provider())
876 != app.provider_identity_for_persistence();
877 if provider_changed {
878 let identity = match config
879 .resolve_provider_identity(app.provider_identity_for_persistence())
880 {
881 Ok(identity) => identity,
882 Err(err) => {
883 app.status_message =
884 Some(format!("Failed to restore saved session provider: {err}"));
885 return Ok(false);
886 }
887 };
888 restore_loaded_session_provider(app, config, identity);
889 config.set_provider_model_override(app.api_provider, Some(model.clone()));
890 }
891 // Re-resolve from the live config even when the provider did
892 // not change. The command layer intentionally has no Config
893 // handle, so its provisional limits cannot include current
894 // provider overrides.
895 resolve_loaded_session_route(app, config);
896 app.update_model_compaction_budget();
897 if provider_changed || workspace_changed {
898 let _ = engine_handle.send(Op::Shutdown).await;
899 *engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
900 }
901 // SyncSession carries the conversation but not resolved route
902 // limits. Refresh the engine's model first so a loaded,
903 // forked, or freshly reset session cannot retain the previous
904 // route's context/output facts.
905 let _ = engine_handle
906 .send(Op::SetModel {
907 model: model.clone(),
908 mode,
909 route_limits: app.active_route_limits,
910 })
911 .await;
912 let _ = engine_handle
913 .send(Op::SyncSession {
914 session_id,
915 messages,
916 system_prompt,
917 system_prompt_override: false,
918 model,
919 workspace,
920 mode,
921 })
922 .await;
923 let _ = engine_handle
924 .send(Op::SetCompaction {
925 config: app.compaction_config(),
926 })
927 .await;
928 if is_full_reset {
929 persist_full_reset_snapshot(app);
930 }
931 }
932 AppAction::ModeChanged(_mode) => {
933 sync_mode_update(app, engine_handle).await;
934 }
935 AppAction::ApprovalPolicyPersisted { policy } => {
936 config.approval_policy = policy;
937 sync_mode_update(app, engine_handle).await;
938 }
939 AppAction::PermissionRulesChanged => {
940 match codewhale_config::load_permissions_snapshot(app.config_path.clone()) {
941 Ok(snapshot) => {
942 let ruleset = snapshot.permissions().ruleset();
943 config.exec_policy_engine.set_ruleset(ruleset.clone());
944 if let Err(error) = engine_handle
945 .send(Op::SetPermissionRuleset { ruleset })
946 .await
947 {
948 app.status_message = Some(
949 tr(app.ui_locale, MessageId::PermissionsOperationFailed)
950 .replace("{error}", &error.to_string()),
951 );
952 }
953 }
954 Err(error) => {
955 app.status_message = Some(
956 tr(app.ui_locale, MessageId::PermissionsOperationFailed)
957 .replace("{error}", &format!("{error:#}")),
958 );
959 }
960 }
961 }
962 AppAction::PluginRegistryChanged => {
963 let _ = engine_handle.send(Op::Shutdown).await;
964 *engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
965 if !app.api_messages.is_empty() {
966 let _ = engine_handle
967 .send(Op::SyncSession {
968 session_id: app.current_session_id.clone(),
969 messages: app.api_messages.clone(),
970 system_prompt: app.system_prompt.clone(),
971 system_prompt_override: false,
972 model: app.model.clone(),
973 workspace: app.workspace.clone(),
974 mode: app.mode,
975 })
976 .await;
977 }
978 }
979 AppAction::SendMessage(content) => {
980 let queued = build_queued_message(app, content);
981 dispatch_composer_message(
982 app,
983 config,
984 engine_handle,
985 queued,
986 DispatchRecovery::Immediate,
987 ComposerSubmitAction::Submit(app.decide_submit_disposition()),
988 )
989 .await?;
990 }
991 AppAction::SetGoalStatus { status, clear } => {
992 let _ = engine_handle
993 .send(Op::SetGoalStatus { status, clear })
994 .await;
995 }
996 AppAction::OpenTextPager { title, content } => {
997 open_text_pager(app, title, content);
998 }
999 AppAction::VoiceCapture => {
1000 use commands::voice::VoiceCaptureOutcome;
1001 match commands::voice::capture_and_transcribe(app, config).await {
1002 Ok(VoiceCaptureOutcome::Insert(text)) => {
1003 app.insert_str(&text);
1004 app.status_message = Some(format!(
1005 "{}: {text}",
1006 tr(app.ui_locale, MessageId::VoiceTranscribed)
1007 ));
1008 }
1009 Ok(VoiceCaptureOutcome::Send(content)) => {
1010 app.status_message =
1011 Some(tr(app.ui_locale, MessageId::VoiceTranscribed).to_string());
1012 let queued = build_queued_message(app, content);
1013 dispatch_composer_message(
1014 app,
1015 config,
1016 engine_handle,
1017 queued,
1018 DispatchRecovery::Immediate,
1019 ComposerSubmitAction::Submit(app.decide_submit_disposition()),
1020 )
1021 .await?;
1022 }
1023 Err(err) => {
1024 app.voice_enabled = false;
1025 app.status_message = Some(err);
1026 }
1027 }
1028 }
1029 AppAction::ListSubAgents => {
1030 // #3802: non-blocking send — refresh op, safe to drop.
1031 let _ = engine_handle.try_send(Op::ListSubAgents);
1032 }
1033 AppAction::PreviewOutboundRequest {
1034 json,
1035 base_prompt_only,
1036 hypothetical_prompt,
1037 } => {
1038 // Split of authority: the host resolves the next turn's route
1039 // with the same planner it would use to send one, and the
1040 // engine — the only place that can rebuild the tool catalog,
1041 // MCP state, gates, system prompt, and prepared body — turns
1042 // that plan into a manifest.
1043 let inputs =
1044 build_preview_request_inputs(app, config, engine_handle, hypothetical_prompt)
1045 .await;
1046 if let Err(err) = engine_handle
1047 .send(Op::PreviewOutboundRequest {
1048 inputs: Box::new(inputs),
1049 json,
1050 base_prompt_only,
1051 })
1052 .await
1053 {
1054 app.status_message = Some(format!("Cannot preview request: {err}"));
1055 }
1056 }
1057 AppAction::CancelSubAgent { agent_id } => {
1058 app.status_message = Some(format!("Cancelling {agent_id}..."));
1059 if engine_handle
1060 .send(Op::CancelSubAgent {
1061 agent_id: agent_id.clone(),
1062 })
1063 .await
1064 .is_err()
1065 {
1066 app.status_message = Some(format!("Could not cancel {agent_id}"));
1067 }
1068 }
1069 AppAction::FetchModels => {
1070 app.status_message = Some("Fetching models...".to_string());
1071 match fetch_available_models(config).await {
1072 Ok(models) => {
1073 app.add_message(HistoryCell::System {
1074 content: format_helpers::available_models_message(&app.model, &models),
1075 });
1076 app.status_message = Some(format!("Found {} model(s)", models.len()));
1077 }
1078 Err(error) => {
1079 app.add_message(HistoryCell::System {
1080 content: format!(
1081 "Failed to fetch models from {}: {error}",
1082 config.api_provider().display_name()
1083 ),
1084 });
1085 }
1086 }
1087 }
1088 AppAction::RefreshModelsDevCatalog => {
1089 app.status_message = Some("Refreshing Models.dev catalog...".to_string());
1090 let message = match crate::models_dev_live::refresh(true).await {
1091 Ok(count) => {
1092 let status = crate::models_dev_live::status();
1093 let source = if status.source_label.is_empty() {
1094 "unknown"
1095 } else {
1096 status.source_label.as_str()
1097 };
1098 format!(
1099 "Models.dev catalog refreshed: {count} offerings ({:?}, source {source})",
1100 status.freshness
1101 )
1102 }
1103 Err(err) => {
1104 let status = crate::models_dev_live::status();
1105 format!(
1106 "Models.dev refresh failed ({err}); keeping prior/bundled rows ({} offerings, {:?})",
1107 status.offering_count, status.freshness
1108 )
1109 }
1110 };
1111 app.add_message(HistoryCell::System {
1112 content: message.clone(),
1113 });
1114 app.status_message = Some(message);
1115 }
1116 AppAction::CacheWarmup => {
1117 app.status_message = Some("Warming prompt cache...".to_string());
1118 match run_cache_warmup(app, config).await {
1119 Ok(outcome) => {
1120 app.session.last_base_url = Some(outcome.base_url.clone());
1121 app.session.last_warmup_key = Some(CacheWarmupKey::from_inspection(
1122 &outcome.provider_identity,
1123 &outcome.model,
1124 &outcome.base_url,
1125 &outcome.inspection,
1126 ));
1127 let mut message = format_helpers::cache_warmup_result(&outcome.usage);
1128 if let Some(key) = app.session.last_warmup_key.as_ref() {
1129 message.push_str(&format!("\nWarmup key: {}", key.hash_short()));
1130 }
1131 // Append prefix-cache stability info.
1132 if app.prefix_checks_total > 0 {
1133 let changes = app.prefix_change_count;
1134 let total = app.prefix_checks_total;
1135 let stable = total.saturating_sub(changes);
1136 let pct = app
1137 .prefix_stability_pct
1138 .map(|p| format!("{p}%"))
1139 .unwrap_or_else(|| "--".to_string());
1140 message.push_str(&format!(
1141 "\n\nPrefix stability: {pct} ({stable}/{total} checks stable, {changes} change{})",
1142 if changes == 1 { "" } else { "s" }
1143 ));
1144 if let Some(ref desc) = app.last_prefix_change_desc {
1145 message.push_str(&format!("\nLast prefix change: {desc}"));
1146 }
1147 }
1148 app.add_message(HistoryCell::System { content: message });
1149 app.status_message = Some("Cache warmup complete".to_string());
1150 }
1151 Err(error) => {
1152 app.add_message(HistoryCell::System {
1153 content: format!("Cache warmup failed: {error}"),
1154 });
1155 app.status_message = Some("Cache warmup failed".to_string());
1156 }
1157 }
1158 }
1159 AppAction::SwitchProvider { provider, model } => {
1160 switch_provider(app, engine_handle, config, provider, model).await;
1161 // Refresh balance after provider switch.
1162 let balance_cooldown_expired = app
1163 .last_balance_fetch
1164 .is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
1165 if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
1166 let cell = app.balance_cell.clone();
1167 let api_key = config.deepseek_api_key().unwrap_or_default();
1168 let base_url = config.deepseek_base_url();
1169 if !api_key.is_empty() {
1170 app.last_balance_fetch = Some(Instant::now());
1171 tokio::spawn(async move {
1172 if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await
1173 && let Ok(mut guard) = cell.lock()
1174 {
1175 *guard = Some(info);
1176 }
1177 });
1178 }
1179 } else {
1180 // Clear balance when switching to a non-DeepSeek provider.
1181 if let Ok(mut guard) = app.balance_cell.lock() {
1182 *guard = None;
1183 }
1184 }
1185 }
1186 AppAction::SwitchModelRoute { provider, model } => {
1187 let previous_model = if app.auto_model {
1188 "auto".to_string()
1189 } else {
1190 app.model.clone()
1191 };
1192 // Hotbar route actions do not carry an effort choice. Preserve
1193 // the raw global preference instead of feeding a fixed
1194 // route's normalized live tier back through the picker path.
1195 let previous_effort = app
1196 .reasoning_effort_preference
1197 .unwrap_or(app.reasoning_effort);
1198 apply_model_picker_choice(
1199 app,
1200 engine_handle,
1201 config,
1202 model,
1203 Some(provider),
1204 None,
1205 previous_effort,
1206 previous_model,
1207 previous_effort,
1208 // A hotbar route switch is a session action, not a
1209 // statement about what the next launch should open with.
1210 false,
1211 )
1212 .await;
1213 }
1214 AppAction::UpdateCompaction(compaction) => {
1215 if app.is_loading || app.is_compacting {
1216 let queued = try_apply_model_and_compaction_update(
1217 engine_handle,
1218 compaction,
1219 app.mode,
1220 app.active_route_limits,
1221 );
1222 app.status_message = Some(if queued {
1223 "Config change queued; the active turn remains responsive.".to_string()
1224 } else {
1225 "Config change deferred; it will apply to the next turn.".to_string()
1226 });
1227 } else {
1228 apply_model_and_compaction_update(
1229 engine_handle,
1230 compaction,
1231 app.mode,
1232 app.active_route_limits,
1233 )
1234 .await;
1235 }
1236 }
1237 AppAction::UpdateStreamChunkTimeout(timeout_secs) => {
1238 let _ = engine_handle
1239 .send(Op::SetStreamChunkTimeout { timeout_secs })
1240 .await;
1241 }
1242 AppAction::UpdateSubagentRuntimeConfig {
1243 enabled,
1244 max_subagents,
1245 launch_concurrency,
1246 max_spawn_depth,
1247 api_timeout_secs,
1248 heartbeat_timeout_secs,
1249 } => {
1250 let _ = engine_handle
1251 .send(Op::SetSubagentRuntimeConfig {
1252 enabled,
1253 max_subagents,
1254 launch_concurrency,
1255 max_spawn_depth,
1256 api_timeout_secs,
1257 heartbeat_timeout_secs,
1258 })
1259 .await;
1260 }
1261 AppAction::SetAdvisorEnabled { enabled } => {
1262 let _ = engine_handle.send(Op::SetAdvisorEnabled { enabled }).await;
1263 }
1264 AppAction::OpenConfigEditor(mode) => match mode {
1265 ConfigUiMode::Native => {
1266 if app.view_stack.top_kind() != Some(ModalKind::Config) {
1267 app.view_stack.push(ConfigView::new_for_app(app));
1268 }
1269 }
1270 ConfigUiMode::Tui => {
1271 pause_terminal(
1272 terminal,
1273 app.use_alt_screen,
1274 app.use_mouse_capture,
1275 app.use_bracketed_paste,
1276 )?;
1277 let editor_result = config_ui::run_tui_editor(app, config)
1278 .and_then(|doc| config_ui::apply_document(doc, app, config, true));
1279 resume_terminal(
1280 terminal,
1281 app.use_alt_screen,
1282 app.use_mouse_capture,
1283 app.use_bracketed_paste,
1284 app.synchronized_output_enabled,
1285 )?;
1286 match editor_result {
1287 Ok(outcome) => {
1288 if outcome.requires_engine_sync {
1289 apply_model_and_compaction_update(
1290 engine_handle,
1291 app.compaction_config(),
1292 app.mode,
1293 app.active_route_limits,
1294 )
1295 .await;
1296 }
1297 app.add_message(HistoryCell::System {
1298 content: outcome.final_message.clone(),
1299 });
1300 app.status_message = Some(outcome.final_message);
1301 }
1302 Err(err) => {
1303 app.add_message(HistoryCell::System {
1304 content: format!("Config UI failed: {err}"),
1305 });
1306 }
1307 }
1308 }
1309 ConfigUiMode::Web => {
1310 #[cfg(feature = "web")]
1311 {
1312 let session = config_ui::start_web_editor(app, config).await?;
1313 let url = format!("http://{}", session.addr);
1314 let open_err = config_ui::open_browser(&url).err();
1315 if let Some(err) = open_err {
1316 app.add_message(HistoryCell::System {
1317 content: format!("Failed to open browser automatically: {err}"),
1318 });
1319 }
1320 app.status_message = Some(format!("web ui listen on: {url}"));
1321 *web_config_session = Some(session);
1322 }
1323 #[cfg(not(feature = "web"))]
1324 {
1325 app.add_message(HistoryCell::System {
1326 content: "This build does not include the web config UI.".to_string(),
1327 });
1328 }
1329 }
1330 },
1331 AppAction::OpenConfigView => {
1332 if app.view_stack.top_kind() != Some(ModalKind::Config) {
1333 app.view_stack.push(ConfigView::new_for_app(app));
1334 }
1335 }
1336 AppAction::OpenWorktreeManager => {
1337 if app.view_stack.top_kind() != Some(ModalKind::WorktreeManager) {
1338 // Non-blocking: git_status caches; manager never shells on paint.
1339 crate::tui::git_status::refresh_if_stale(&app.workspace);
1340 app.view_stack
1341 .push(crate::tui::worktree_manager::WorktreeManagerView::new(
1342 app.workspace.clone(),
1343 ));
1344 }
1345 }
1346 AppAction::OpenModelPicker => {
1347 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
1348 app.view_stack
1349 .push(crate::tui::model_picker::ModelPickerView::new(app, config));
1350 }
1351 }
1352 AppAction::OpenProviderPicker => {
1353 if app.onboarding == OnboardingState::Provider {
1354 open_onboarding_provider_picker(app, config, engine_handle, true).await;
1355 } else if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
1356 let runtime_status = query_provider_runtime_status(engine_handle).await;
1357 app.view_stack.push(
1358 crate::tui::provider_picker::ProviderPickerView::new_with_runtime_status_and_memory(
1359 app.api_provider,
1360 config,
1361 runtime_status,
1362 app.provider_picker_memory.as_ref(),
1363 )
1364 .with_locale(app.ui_locale)
1365 .with_provider_health(&app.provider_health),
1366 );
1367 }
1368 }
1369 AppAction::OpenProviderSetup { provider } => {
1370 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
1371 let runtime_status = query_provider_runtime_status(engine_handle).await;
1372 app.view_stack.push(
1373 crate::tui::provider_picker::ProviderPickerView::new_for_setup(
1374 app.api_provider,
1375 provider,
1376 config,
1377 runtime_status,
1378 )
1379 .with_locale(app.ui_locale)
1380 .with_provider_health(&app.provider_health),
1381 );
1382 app.status_message = Some("Provider setup catalog opened.".to_string());
1383 }
1384 }
1385 AppAction::StartXaiDeviceLogin => {
1386 let _switched =
1387 run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
1388 }
1389 AppAction::OpenModePicker => {
1390 if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
1391 app.view_stack
1392 .push(crate::tui::views::mode_picker::ModePickerView::new(
1393 app.mode,
1394 app.ui_locale,
1395 ));
1396 }
1397 }
1398 AppAction::OpenStatusPicker => {
1399 if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
1400 app.view_stack
1401 .push(crate::tui::views::status_picker::StatusPickerView::new(
1402 &app.status_items,
1403 app.api_provider,
1404 app.ui_locale,
1405 ));
1406 }
1407 }
1408 AppAction::OpenFeedbackPicker => {
1409 if app.view_stack.top_kind() != Some(ModalKind::FeedbackPicker) {
1410 app.view_stack
1411 .push(crate::tui::feedback_picker::FeedbackPickerView::new());
1412 }
1413 }
1414 AppAction::OpenThemePicker => {
1415 if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
1416 // Capture the active theme name straight from `app` so
1417 // Esc can revert through the same ConfigUpdated channel.
1418 // Avoids re-reading settings.toml from disk on every
1419 // `/theme` invocation.
1420 let original = app.theme_id.name().to_string();
1421 app.view_stack.push_boxed(
1422 crate::tui::theme_picker::ThemePickerView::boxed_with_treatment(
1423 original,
1424 app.ocean_treatment,
1425 app.ui_locale,
1426 app.background_color_override,
1427 ),
1428 );
1429 }
1430 }
1431 AppAction::OpenSkillsManager => {
1432 if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
1433 app.view_stack
1434 .push(crate::tui::views::skills_manager::SkillsManagerView::new(
1435 app,
1436 ));
1437 }
1438 }
1439 AppAction::OpenFleetList => {
1440 if app.view_stack.top_kind() != Some(ModalKind::FleetList) {
1441 app.view_stack
1442 .push(crate::tui::views::fleet_list::FleetListView::new(
1443 app, config,
1444 ));
1445 }
1446 }
1447 AppAction::OpenFleetRoster => {
1448 if app.view_stack.top_kind() != Some(ModalKind::FleetRoster) {
1449 app.view_stack
1450 .push(crate::tui::views::fleet_roster::FleetRosterView::new(
1451 app, config,
1452 ));
1453 }
1454 }
1455 AppAction::OpenFleetSetup => {
1456 if app.view_stack.top_kind() != Some(ModalKind::FleetSetup) {
1457 let _ = app.next_draft_gen();
1458 app.view_stack
1459 .push(crate::tui::views::fleet_setup::FleetSetupView::new(
1460 app, config,
1461 ));
1462 }
1463 }
1464 AppAction::OpenHotbarSetup => {
1465 if app.view_stack.top_kind() != Some(ModalKind::HotbarSetup) {
1466 app.view_stack
1467 .push(crate::tui::hotbar::setup::HotbarSetupView::new(app, config));
1468 }
1469 }
1470 AppAction::OpenSetupWizard => {
1471 if app.view_stack.top_kind() != Some(ModalKind::SetupWizard) {
1472 let _ = app.next_draft_gen();
1473 app.view_stack
1474 .push(crate::tui::setup::SetupWizardView::new_for_app(app, config));
1475 }
1476 }
1477 AppAction::OpenSetupWizardAt { step } => {
1478 if app.view_stack.top_kind() != Some(ModalKind::SetupWizard) {
1479 let _ = app.next_draft_gen();
1480 app.view_stack
1481 .push(crate::tui::setup::SetupWizardView::new_for_app_at(
1482 app, config, step,
1483 ));
1484 }
1485 }
1486 AppAction::UseBundledConstitution => use_bundled_constitution(app, config),
1487 AppAction::PreviewEffectiveBasePrompt => preview_effective_base_prompt(app, config),
1488 AppAction::DisableHotbar => disable_hotbar(app, config),
1489 AppAction::RestoreHotbarDefaults => restore_hotbar_defaults(app, config),
1490 AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) {
1491 Ok(()) => {
1492 app.status_message = Some(format!("Opened {label} in your browser"));
1493 }
1494 Err(err) => {
1495 app.add_message(HistoryCell::System {
1496 content: format!(
1497 "Could not open {label} automatically: {err}\n\nThe URL is printed above."
1498 ),
1499 });
1500 }
1501 },
1502 AppAction::OpenContextInspector => {
1503 open_context_inspector(app);
1504 }
1505 AppAction::OpenLiveTranscript => {
1506 open_live_transcript_overlay(app);
1507 }
1508 AppAction::OpenTurnInspector => {
1509 open_turn_inspector_pager(app);
1510 }
1511 AppAction::CompactContext { focus } => {
1512 app.status_message = Some("Compacting context...".to_string());
1513 match validated_app_runtime_route(app, config) {
1514 Ok(route) => {
1515 let mut compaction = compaction_for_validated_route(app, &route);
1516 compaction.focus = focus.clone();
1517 let _ = engine_handle
1518 .send(Op::CompactContext {
1519 route: Box::new(route.into_resolved()),
1520 compaction: Box::new(compaction),
1521 })
1522 .await;
1523 }
1524 Err(err) => {
1525 app.status_message = Some(format!(
1526 "Cannot compact because the active provider route is invalid: {err}"
1527 ));
1528 }
1529 }
1530 }
1531 AppAction::PurgeContext => {
1532 app.status_message = Some("Agent purging context...".to_string());
1533 let _ = engine_handle.send(Op::PurgeContext).await;
1534 }
1535 AppAction::TaskAdd { prompt } => {
1536 let request = NewTaskRequest {
1537 prompt: prompt.clone(),
1538 model: Some(app.model.clone()),
1539 workspace: Some(app.workspace.clone()),
1540 mode: Some(task_mode_label(app.mode).to_string()),
1541 allow_shell: Some(app.allow_shell),
1542 trust_mode: Some(app.trust_mode),
1543 auto_approve: Some(app_auto_approve_enabled(app)),
1544 owner_session_id: app.current_session_id.clone(),
1545 };
1546 match task_manager.add_task(request).await {
1547 Ok(task) => {
1548 app.add_message(HistoryCell::System {
1549 content: format!(
1550 "Task queued: {} ({})",
1551 task.id,
1552 summarize_tool_output(&task.prompt)
1553 ),
1554 });
1555 app.status_message = Some(format!("Queued {}", task.id));
1556 }
1557 Err(err) => {
1558 app.add_message(HistoryCell::System {
1559 content: format!("Failed to queue task: {err}"),
1560 });
1561 }
1562 }
1563 refresh_active_task_panel(app, task_manager).await;
1564 }
1565 AppAction::TaskList => {
1566 let tasks = task_manager.list_tasks(Some(30)).await;
1567 refresh_active_task_panel(app, task_manager).await;
1568 app.add_message(HistoryCell::System {
1569 content: format_task_list(&tasks),
1570 });
1571 }
1572 AppAction::RemoteControl(action) => match action {
1573 crate::remote_control::RemoteControlAction::Start => {
1574 start_remote_control_session(app);
1575 }
1576 crate::remote_control::RemoteControlAction::Stop => {
1577 app.remote_control.stop();
1578 let status = app.remote_control.status_line();
1579 if app.remote_control.blocks_local_input() {
1580 app.sticky_status = Some(StatusToast::new(
1581 status.clone(),
1582 StatusToastLevel::Warning,
1583 None,
1584 ));
1585 } else {
1586 app.sticky_status = None;
1587 }
1588 app.status_message = Some(status);
1589 }
1590 },
1591 AppAction::TaskShow { id } => match task_manager.get_task(&id).await {
1592 Ok(task) => open_task_pager(app, &task),
1593 Err(err) => {
1594 app.add_message(HistoryCell::System {
1595 content: format!("Task lookup failed: {err}"),
1596 });
1597 }
1598 },
1599 AppAction::TaskCancel { id } => {
1600 match task_manager.cancel_task(&id).await {
1601 Ok(cancellation) => {
1602 app.add_message(HistoryCell::System {
1603 content: format!(
1604 "Task {} status: {:?}",
1605 cancellation.task.id, cancellation.task.status
1606 ),
1607 });
1608 }
1609 Err(err) => {
1610 app.add_message(HistoryCell::System {
1611 content: format!("Task cancel failed: {err}"),
1612 });
1613 }
1614 }
1615 refresh_active_task_panel(app, task_manager).await;
1616 }
1617 AppAction::Automation(action) => {
1618 crate::tui::automation_routing::handle_action(app, action, task_manager).await;
1619 }
1620 AppAction::ShellJob(action) => {
1621 handle_shell_job_action(app, action);
1622 // Immediately sync the task panel after cancel/poll so the
1623 // Activity sidebar stays accurate without waiting for the
1624 // next 2.5 s periodic refresh (#2937).
1625 refresh_active_task_panel(app, task_manager).await;
1626 }
1627 AppAction::Mcp(action) => {
1628 handle_mcp_ui_action(app, engine_handle, config, action).await;
1629 }
1630 AppAction::SwitchWorkspace { workspace } => {
1631 switch_workspace(app, engine_handle, task_manager, config, workspace).await;
1632 }
1633 AppAction::SwitchProfile { profile } => {
1634 let previous_profile = app.config_profile.clone();
1635 match Config::load(app.config_path.clone(), Some(&profile)).and_then(|new_config| {
1636 validated_profile_default_route(&new_config)
1637 .map(|validated_route| (new_config, validated_route))
1638 }) {
1639 Ok((new_config, validated_route)) => {
1640 let new_model = validated_route.model.clone();
1641 let provider_identity = validated_route.identity.clone();
1642 let route_limits = crate::route_budget::known_route_limits(
1643 validated_route.candidate.limits(),
1644 );
1645 app.config_profile = Some(profile.clone());
1646 *config = new_config.clone();
1647 app.set_provider_identity_record(provider_identity);
1648 app.billing_presentation =
1649 crate::route_billing::for_route(config, app.api_provider);
1650 app.set_model_selection(new_model.clone());
1651 app.set_active_context_window_override(
1652 config.context_window_for_provider_config(app.api_provider),
1653 );
1654 app.active_route_limits = route_limits;
1655 app.update_model_compaction_budget();
1656 app.session.last_prompt_tokens = None;
1657 app.session.last_completion_tokens = None;
1658 app.session.last_output_throughput = None;
1659 // Rebuild the engine with the new config so API key/model/base URL take effect.
1660 let _ = engine_handle.send(Op::Shutdown).await;
1661 let engine_config = build_engine_config(app, config);
1662 *engine_handle = spawn_tui_engine(engine_config, config);
1663 if !app.api_messages.is_empty() {
1664 let _ = engine_handle
1665 .send(Op::SyncSession {
1666 session_id: app.current_session_id.clone(),
1667 messages: app.api_messages.clone(),
1668 system_prompt: app.system_prompt.clone(),
1669 system_prompt_override: false,
1670 model: app.model.clone(),
1671 workspace: app.workspace.clone(),
1672 mode: app.mode,
1673 })
1674 .await;
1675 }
1676 app.add_message(HistoryCell::System {
1677 content: format!(
1678 "Switched to profile '{profile}'. Model: {new_model}, Provider: {}",
1679 app.provider_identity_for_persistence()
1680 ),
1681 });
1682 app.status_message = Some(format!("Profile: {profile}"));
1683 }
1684 Err(err) => {
1685 app.config_profile = previous_profile;
1686 app.status_message =
1687 Some(format!("Failed to switch to profile '{profile}': {err}"));
1688 }
1689 }
1690 }
1691 AppAction::ShareSession {
1692 history_len: _,
1693 model,
1694 mode,
1695 } => {
1696 let status = if app.api_messages.is_empty() {
1697 "No session content to share.".to_string()
1698 } else {
1699 let history_json = serde_json::to_string_pretty(&app.api_messages)
1700 .unwrap_or_else(|_| "[]".to_string());
1701 match crate::commands::share::perform_share(&history_json, &model, &mode).await
1702 {
1703 Ok(url) => format!("Session shared! URL: {url}"),
1704 Err(err) => format!("Share failed: {err}"),
1705 }
1706 };
1707 app.add_message(HistoryCell::System {
1708 content: status.clone(),
1709 });
1710 app.status_message = Some(status);
1711 }
1712 }
1713 }
1714
1715 Ok(false)
1716 }
1717
1718 pub(crate) fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) {
1719 app.workspace = workspace.clone();
1720 app.coordination_detail = None;
1721 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
1722 app.active_skill = None;
1723 app.active_skill_provenance = None;
1724 // Switching workspace reloads the hook set (project hooks are per-repo)
1725 // but stays inside the same TUI session, so the session id is preserved.
1726 app.hooks = app.hooks.rebind(
1727 crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace),
1728 workspace.clone(),
1729 );
1730 app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config);
1731 app.skills_scan_codewhale_only = config.skills_config().scan_codewhale_only();
1732 app.project_context_pack_enabled = config.project_context_pack_enabled();
1733 app.refresh_skill_cache();
1734 app.workspace_context = None;
1735 if let Ok(mut cell) = app.workspace_context_cell.lock() {
1736 *cell = None;
1737 }
1738 app.workspace_context_refreshed_at = None;
1739 app.file_tree = None;
1740
1741 let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace);
1742 app.runtime_services.shell_manager = Some(shell_manager);
1743 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
1744 }
1745
1746 pub(crate) fn apply_hotbar_setup_saved(
1747 app: &mut App,
1748 config: &mut Config,
1749 bindings: Vec<codewhale_config::HotbarBindingToml>,
1750 ) {
1751 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &bindings)
1752 {
1753 Ok(path) => {
1754 config.hotbar = Some(bindings);
1755 app.status_message = Some(format!("Hotbar bindings saved to {}", path.display()));
1756 }
1757 Err(err) => {
1758 app.status_message = Some(format!("Failed to save Hotbar bindings: {err}"));
1759 app.add_message(HistoryCell::System {
1760 content: format!("Failed to save Hotbar bindings: {err}"),
1761 });
1762 }
1763 }
1764 app.needs_redraw = true;
1765 }
1766
1767 pub(crate) async fn apply_approval_decision(
1768 app: &mut App,
1769 engine_handle: &mut EngineHandle,
1770 config: &mut Config,
1771 event: ApprovalDecisionEvent,
1772 ) {
1773 if event.decision == ReviewDecision::ApprovedForSession {
1774 // Store the tool name (backward compat) and the lossy grouping key so
1775 // later flag variants of the same command family are also auto-approved
1776 // (v0.8.37).
1777 app.approval_session_approved
1778 .insert(event.tool_name.clone());
1779 app.approval_session_approved
1780 .insert(event.approval_grouping_key.clone());
1781 }
1782
1783 if matches!(
1784 event.decision,
1785 ReviewDecision::Approved | ReviewDecision::ApprovedForSession
1786 ) && !event.persistent_rules.is_empty()
1787 && !event.timed_out
1788 {
1789 persist_rules_from_approval(app, config, &event.persistent_rules);
1790 }
1791
1792 match event.decision {
1793 ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
1794 let _ = engine_handle.approve_tool_call(event.tool_id).await;
1795 }
1796 ReviewDecision::Denied => {
1797 // Cache the denial so the model retry-loop doesn't re-prompt for
1798 // the exact same approval_key (#360). Only the key (per-call
1799 // unique) is stored — NOT the tool_name, which would block all
1800 // future invocations of the same tool type (#1377).
1801 if !event.timed_out {
1802 app.approval_session_denied.insert(event.approval_key);
1803 }
1804 let _ = engine_handle.deny_tool_call(event.tool_id).await;
1805 }
1806 ReviewDecision::Abort => {
1807 engine_handle.cancel();
1808 mark_active_turn_cancelled_locally(app);
1809 app.status_message = Some(parent_stop_status(app, "Request cancelled"));
1810 }
1811 }
1812 }
1813
1814 pub(crate) fn apply_setup_runtime_preset(
1815 app: &mut App,
1816 config: &mut Config,
1817 preset: crate::tui::setup::SetupRuntimePreset,
1818 state: codewhale_config::SetupState,
1819 ) -> Result<String> {
1820 if let Some(source) = config.runtime_preset_blocker(
1821 app.config_path.as_deref(),
1822 app.config_profile.as_deref(),
1823 &app.workspace,
1824 ) {
1825 anyhow::bail!(
1826 "Runtime presets cannot override {source}; change that controlling source first"
1827 );
1828 }
1829 if preset == crate::tui::setup::SetupRuntimePreset::HighTrustLocal {
1830 let approval = config.approval_policy_control(
1831 app.config_path.as_deref(),
1832 app.config_profile.as_deref(),
1833 &app.workspace,
1834 );
1835 if !approval.editable_root() {
1836 anyhow::bail!(
1837 "Full Access cannot override {}; change that controlling source first",
1838 approval.label()
1839 );
1840 }
1841 }
1842
1843 let settings_path = Settings::path().context("failed to resolve settings path")?;
1844 let settings_snapshot = RuntimePresetFileSnapshot::capture(settings_path)?;
1845 // The preset's settings read, its config-document write, and its settings
1846 // write are one durable transaction with file-snapshot rollback. Hold the
1847 // settings transaction lock across all of it so a concurrent writer (a queued
1848 // mode/thinking drain, the Shift+Tab posture write) can neither be lost by
1849 // this save nor be reverted by the rollback.
1850 // Every durable write happens inside this closure, so the settings lock is
1851 // released before live state moves below.
1852 crate::settings::with_settings_transaction(|settings_transaction| {
1853 let mut settings = settings_transaction
1854 .load()
1855 .context("failed to load settings")?;
1856 settings.default_mode = preset.default_mode().to_string();
1857 settings.permission_posture = Some(preset.permission_posture().to_string());
1858
1859 // Persist into the same file Config::load actually selected. A missing
1860 // explicit env target remains authoritative for both reads and writes;
1861 // an invalid target fails here instead of selecting a different file.
1862 let selected_config_path =
1863 crate::config::resolve_load_config_path(app.config_path.clone())?
1864 .or_else(|| app.config_path.clone());
1865 let config_path =
1866 crate::config_persistence::config_toml_path(selected_config_path.as_deref())
1867 .context("failed to resolve config path")?;
1868 let config_snapshot = RuntimePresetFileSnapshot::capture(config_path.clone())?;
1869 if let Err(error) =
1870 crate::config_persistence::mutate_config_document(&config_path, |document| {
1871 if let Some(policy) = preset.approval_policy() {
1872 crate::config_persistence::set_document_value(
1873 document,
1874 &["approval_policy"],
1875 policy,
1876 )?;
1877 } else {
1878 crate::config_persistence::unset_document_value(
1879 document,
1880 &["approval_policy"],
1881 )?;
1882 }
1883 crate::config_persistence::set_document_value(
1884 document,
1885 &["allow_shell"],
1886 preset.allow_shell(),
1887 )?;
1888 crate::config_persistence::set_document_value(
1889 document,
1890 &["sandbox_mode"],
1891 preset.sandbox_mode(),
1892 )
1893 })
1894 .context("failed to persist runtime posture")
1895 {
1896 return Err(runtime_preset_error_with_rollback(
1897 error,
1898 &[&settings_snapshot, &config_snapshot],
1899 ));
1900 }
1901 if let Err(error) = settings_transaction
1902 .save(&settings)
1903 .context("failed to save settings")
1904 {
1905 return Err(runtime_preset_error_with_rollback(
1906 error,
1907 &[&settings_snapshot, &config_snapshot],
1908 ));
1909 }
1910 if let Err(error) = state
1911 .save()
1912 .context("failed to persist setup runtime posture state")
1913 {
1914 return Err(runtime_preset_error_with_rollback(
1915 error,
1916 &[&settings_snapshot, &config_snapshot],
1917 ));
1918 }
1919 Ok(())
1920 })?;
1921
1922 // Durable writes succeeded as one transaction. Only now may live state
1923 // move to the new posture.
1924 if let Some(policy) = preset.approval_policy() {
1925 config.approval_policy = Some(policy.to_string());
1926 app.mark_approval_policy_locked();
1927 } else {
1928 config.approval_policy = None;
1929 app.clear_saved_approval_policy_lock();
1930 }
1931 config.allow_shell = Some(preset.allow_shell());
1932 config.sandbox_mode = Some(preset.sandbox_mode().to_string());
1933 app.configured_sandbox_mode = config.sandbox_mode.clone();
1934
1935 let approval_mode = ApprovalMode::from_config_value(
1936 preset
1937 .approval_policy()
1938 .unwrap_or(preset.permission_posture()),
1939 )
1940 .unwrap_or(ApprovalMode::Suggest);
1941 let trust_mode = match preset {
1942 crate::tui::setup::SetupRuntimePreset::AskFirst => false,
1943 crate::tui::setup::SetupRuntimePreset::NormalAgent => app.agent_trust_baseline(),
1944 crate::tui::setup::SetupRuntimePreset::HighTrustLocal => true,
1945 };
1946 app.set_agent_runtime_baseline(preset.allow_shell(), trust_mode, approval_mode);
1947 let mode = AppMode::from_setting(preset.default_mode());
1948 app.set_mode(mode);
1949 app.needs_redraw = true;
1950
1951 Ok(format!("Applied {}.", preset.result_summary()))
1952 }
1953
1954 pub(crate) fn apply_backtrack(app: &mut App, depth: usize) {
1955 let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else {
1956 app.status_message = Some("Backtrack target no longer present".to_string());
1957 return;
1958 };
1959
1960 // Snapshot the user text before truncating so we can refill the
1961 // composer.
1962 let user_text = match app.history.get(history_idx) {
1963 Some(HistoryCell::User { content }) => content.clone(),
1964 _ => String::new(),
1965 };
1966
1967 // Trim the visible transcript at the chosen user cell. Per-cell
1968 // revisions and tool-cell maps are kept consistent through
1969 // `App::truncate_history_to`.
1970 app.truncate_history_to(history_idx);
1971
1972 // Trim the API-message log at the matching user PROMPT. `depth` counts
1973 // visible `HistoryCell::User` cells (real prompts), but a naive
1974 // `role == "user"` walk over `api_messages` over-counts: tool results are
1975 // stored as `role == "user"` messages too, so in any turn with tool calls
1976 // the cut would land mid-turn on a tool_result — leaving a dangling
1977 // assistant tool_use with no matching result and a transcript the provider
1978 // rejects. Count only messages that actually yield a User cell, the same
1979 // predicate `apply_loaded_session` uses.
1980 if let Some(idx) = backtrack_api_cut_index(&app.api_messages, depth) {
1981 app.api_messages.truncate(idx);
1982 }
1983
1984 // Hand the dropped text back to the user so they can edit + resend.
1985 app.input = user_text;
1986 app.cursor_position = app.input.chars().count();
1987
1988 // Close the overlay, refresh sticky-tail flag, and surface a hint.
1989 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
1990 app.view_stack.pop();
1991 }
1992 app.status_message =
1993 Some("Rewound to previous user message — edit and Enter to resend".to_string());
1994 app.scroll_to_bottom();
1995 app.mark_history_updated();
1996 app.needs_redraw = true;
1997 }
1998
1999 pub(crate) async fn apply_provider_picker_custom_provider(
2000 app: &mut App,
2001 engine_handle: &mut EngineHandle,
2002 config: &mut Config,
2003 provider_id: String,
2004 base_url: String,
2005 model: Option<String>,
2006 api_key_env: Option<String>,
2007 ) -> bool {
2008 let written = match crate::config_persistence::persist_custom_provider(
2009 app.config_path.as_deref(),
2010 &provider_id,
2011 &base_url,
2012 model.as_deref(),
2013 api_key_env.as_deref(),
2014 ) {
2015 Ok(path) => path,
2016 Err(err) => {
2017 app.add_message(HistoryCell::System {
2018 content: format!("Failed to save custom provider {provider_id}: {err}"),
2019 });
2020 app.status_message = Some("Custom provider was not saved.".to_string());
2021 return false;
2022 }
2023 };
2024
2025 config.provider = Some(provider_id.clone());
2026 let entry = config
2027 .providers
2028 .get_or_insert_with(ProvidersConfig::default)
2029 .custom
2030 .entry(provider_id.clone())
2031 .or_default();
2032 entry.kind = Some("openai-compatible".to_string());
2033 entry.base_url = Some(base_url.trim().trim_end_matches('/').to_string());
2034 entry.model = model.clone().and_then(|value| {
2035 let value = value.trim().to_string();
2036 (!value.is_empty()).then_some(value)
2037 });
2038 entry.api_key_env = api_key_env.and_then(|value| {
2039 let value = value.trim().to_string();
2040 (!value.is_empty()).then_some(value)
2041 });
2042
2043 app.status_message = Some(format!(
2044 "Custom provider {provider_id} saved to {}",
2045 written.display()
2046 ));
2047 switch_provider(app, engine_handle, config, ApiProvider::Custom, model).await
2048 }
2049
2050 pub(crate) async fn apply_provider_picker_api_key(
2051 app: &mut App,
2052 engine_handle: &mut EngineHandle,
2053 config: &mut Config,
2054 identity: crate::config::ProviderIdentity,
2055 api_key: String,
2056 base_url: Option<String>,
2057 ) {
2058 apply_provider_picker_api_key_with_verifier(
2059 app,
2060 engine_handle,
2061 config,
2062 identity,
2063 api_key,
2064 base_url,
2065 &LiveProviderKeyVerifier,
2066 )
2067 .await;
2068 }
2069
2070 pub(crate) async fn apply_provider_picker_api_key_with_verifier(
2071 app: &mut App,
2072 engine_handle: &mut EngineHandle,
2073 config: &mut Config,
2074 identity: crate::config::ProviderIdentity,
2075 api_key: String,
2076 base_url_override: Option<String>,
2077 verifier: &dyn ProviderKeyVerifier,
2078 ) {
2079 let provider = identity.provider;
2080 let mut scoped_config = config.clone();
2081 scoped_config.provider = Some(identity.key.clone());
2082 // #4526: a billing route chosen in the wizard is applied to the scoped
2083 // clone only, so the key is probed against the endpoint it will be saved
2084 // for without touching the on-disk config before the user confirms.
2085 if let Some(base_url) = base_url_override.clone() {
2086 scoped_config.set_provider_base_url_override(provider, Some(base_url));
2087 }
2088 // #3875: verify the key against the provider before opening the rest of
2089 // the guided flow. Nothing is persisted until the confirm stage.
2090 // Resolve the effective route, including compatibility routes whose
2091 // endpoint is selected by auth mode (notably a legacy Kimi CLI import).
2092 // This prevents a replacement Kimi Code API key from being probed against
2093 // the ordinary Moonshot endpoint.
2094 let base_url = scoped_config.deepseek_base_url();
2095 match verifier.verify(provider, &api_key, &base_url).await {
2096 Ok(()) => {
2097 // Key is valid — continue the guided flow at model pick without
2098 // writing the secret yet.
2099 let runtime_status = query_provider_runtime_status(engine_handle).await;
2100 if let Some(picker) =
2101 crate::tui::provider_picker::ProviderPickerView::new_for_model_pick_after_validation(
2102 app.api_provider,
2103 provider,
2104 &scoped_config,
2105 runtime_status,
2106 api_key,
2107 base_url_override,
2108 )
2109 .map(|picker| {
2110 picker
2111 .with_locale(app.ui_locale)
2112 .with_provider_health(&app.provider_health)
2113 })
2114 {
2115 app.view_stack.push(picker);
2116 app.status_message = Some(format!(
2117 "{} API key verified — pick a default model.",
2118 provider.as_str()
2119 ));
2120 } else {
2121 app.status_message = Some(format!(
2122 "{} API key verified, but the guided setup could not be re-opened.",
2123 provider.as_str()
2124 ));
2125 }
2126 app.needs_redraw = true;
2127 }
2128 Err(reason) => {
2129 // Verification failed - keep the picker open at the key-entry
2130 // stage with the provider's actual error so the user can fix
2131 // the key instead of dead-ending with a status toast.
2132 let runtime_status = query_provider_runtime_status(engine_handle).await;
2133 if let Some(picker) =
2134 crate::tui::provider_picker::ProviderPickerView::new_for_key_entry_with_error(
2135 app.api_provider,
2136 provider,
2137 &scoped_config,
2138 runtime_status,
2139 reason,
2140 )
2141 .map(|picker| {
2142 picker
2143 .with_locale(app.ui_locale)
2144 .with_provider_health(&app.provider_health)
2145 })
2146 {
2147 app.view_stack.push(picker);
2148 app.status_message = Some(format!(
2149 "{} API key verification failed - check the key and try again.",
2150 provider.as_str()
2151 ));
2152 } else {
2153 app.status_message = Some(format!(
2154 "{} API key verification failed, but the provider could not be re-opened.",
2155 provider.as_str()
2156 ));
2157 }
2158 app.needs_redraw = true;
2159 }
2160 }
2161 }
2162
2163 #[allow(clippy::too_many_arguments)]
2164 pub(crate) async fn apply_provider_picker_setup_confirmed(
2165 app: &mut App,
2166 engine_handle: &mut EngineHandle,
2167 config: &mut Config,
2168 identity: crate::config::ProviderIdentity,
2169 api_key: String,
2170 model: String,
2171 context_window: Option<u32>,
2172 base_url: Option<String>,
2173 ) -> bool {
2174 use crate::config::{
2175 save_api_key_for_identity, save_provider_base_url_for_identity,
2176 save_provider_context_window_for_identity, save_provider_model_for_identity,
2177 };
2178
2179 let provider = identity.provider;
2180
2181 let model = model.trim().to_string();
2182 if model.is_empty() {
2183 app.add_message(HistoryCell::System {
2184 content: format!(
2185 "Cannot finish {} setup: default model is empty.\nProvider unchanged.",
2186 provider.as_str()
2187 ),
2188 });
2189 return false;
2190 }
2191
2192 // #4526: the wizard's billing-route choice is written before the key so the
2193 // credential is saved onto the route it was verified against. It lands only
2194 // in that provider's own `base_url`; failing here aborts before any secret
2195 // is persisted rather than leaving a key on the wrong endpoint.
2196 if let Some(base_url) = base_url.as_deref() {
2197 if let Err(err) = save_provider_base_url_for_identity(&identity, config, base_url) {
2198 app.add_message(HistoryCell::System {
2199 content: format!(
2200 "Failed to save {} endpoint `{base_url}`: {err}\nProvider unchanged.",
2201 provider.as_str()
2202 ),
2203 });
2204 return false;
2205 }
2206 config.set_provider_base_url_override(provider, Some(base_url.to_string()));
2207 }
2208
2209 // Persist key first via the existing comment-preserving path, then pin the
2210 // chosen default model on the same document when the provider uses a
2211 // `[providers.<name>]` table.
2212 let mut save_confirmation = None;
2213 match save_api_key_for_identity(&identity, config, &api_key) {
2214 Ok(saved) => {
2215 // #5195: name where the key actually landed (secret store backend
2216 // + credential-free config metadata) and the scope it is visible
2217 // from — credential writes are rescoped to the user-global config,
2218 // so the key is available in every folder.
2219 let destination = saved.describe();
2220 if let Err(err) = save_provider_model_for_identity(&identity, config, &model) {
2221 app.add_message(HistoryCell::System {
2222 content: format!(
2223 "Saved {} API key to {destination} (available in all folders), but failed to pin model `{model}`: {err}",
2224 provider.as_str(),
2225 ),
2226 });
2227 } else if let Some(context_window) = context_window {
2228 if let Err(err) =
2229 save_provider_context_window_for_identity(&identity, config, context_window)
2230 {
2231 app.add_message(HistoryCell::System {
2232 content: format!(
2233 "Saved {} API key and model to {destination} (available in all folders), but failed to save context window: {err}",
2234 provider.as_str(),
2235 ),
2236 });
2237 } else {
2238 save_confirmation = Some(format!(
2239 "Saved {} API key, model, and context window to {destination} (available in all folders)",
2240 provider.as_str(),
2241 ));
2242 }
2243 } else {
2244 save_confirmation = Some(format!(
2245 "Saved {} API key and model to {destination} (available in all folders)",
2246 provider.as_str(),
2247 ));
2248 }
2249 app.api_key_env_only = false;
2250 }
2251 Err(err) => {
2252 app.add_message(HistoryCell::System {
2253 content: format!(
2254 "Failed to save {} API key: {err}\nProvider unchanged.",
2255 provider.as_str()
2256 ),
2257 });
2258 return false;
2259 }
2260 }
2261
2262 config.provider = Some(identity.key);
2263 mirror_saved_api_key_in_config(config, provider, api_key);
2264 mirror_saved_model_in_config(config, provider, model.clone());
2265 if let Some(context_window) = context_window {
2266 mirror_saved_context_window_in_config(config, provider, context_window);
2267 }
2268 let switched = switch_provider(app, engine_handle, config, provider, Some(model)).await;
2269 // The switch overwrites the status line with the route summary (the full
2270 // summary also lands in the transcript), so the save confirmation is
2271 // applied last — it is the answer to the action the user just confirmed.
2272 if switched && let Some(confirmation) = save_confirmation {
2273 app.status_message = Some(confirmation);
2274 }
2275 switched
2276 }
2277
2278 pub(crate) async fn apply_codewhale_owned_xai_login(
2279 app: &mut App,
2280 engine_handle: &mut EngineHandle,
2281 config: &mut Config,
2282 pending: crate::xai_oauth::PendingXaiDeviceLogin,
2283 status_prefix: &str,
2284 ) -> bool {
2285 match crate::xai_oauth::activate_device_login(
2286 pending,
2287 app.config_path.as_deref(),
2288 Some(&mut *config),
2289 ) {
2290 Ok(activation) => {
2291 app.status_message = Some(format!(
2292 "{status_prefix}; activated {} via {}",
2293 codewhale_config::quote_os_path(&activation.auth_path),
2294 codewhale_config::quote_os_path(&activation.config_path)
2295 ));
2296 app.api_key_env_only = false;
2297 }
2298 Err(err) => {
2299 app.add_message(HistoryCell::System {
2300 content: format!(
2301 "Failed to finalize {} device login: {err:#}\nProvider unchanged.",
2302 ApiProvider::Xai.as_str()
2303 ),
2304 });
2305 return false;
2306 }
2307 }
2308
2309 switch_provider(app, engine_handle, config, ApiProvider::Xai, None).await
2310 }
2311
2312 pub(crate) fn apply_loaded_session(
2313 app: &mut App,
2314 config: &mut Config,
2315 session: &SavedSession,
2316 ) -> Result<(), String> {
2317 if app.session_transition_blocked() {
2318 return Err(
2319 "runtime work is active; wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work before switching sessions".to_string(),
2320 );
2321 }
2322 let provider_identity = config.resolve_persisted_provider_identity(
2323 Some(&session.metadata.model_provider),
2324 session.metadata.model_provider_id.as_deref(),
2325 )?;
2326 let restored_route = resolve_runtime_route_for_identity(
2327 config,
2328 &provider_identity,
2329 Some(&session.metadata.model),
2330 )
2331 .map_err(|reason| {
2332 format!(
2333 "saved session provider '{}' could not be resolved from the live config: {reason}. Codewhale will not fall back",
2334 provider_identity.key
2335 )
2336 })?;
2337 // Restore/validate the contended state before mutating conversation or
2338 // workspace fields. A failed session switch must leave the current session
2339 // wholly intact.
2340 app.restore_work_state(
2341 &session.metadata.id,
2342 &session.metadata.workspace,
2343 session.work_state.as_ref(),
2344 )?;
2345 // All fallible preflight is complete. Retire the old session's background
2346 // accounting atomically before mutating live state; any late old-scope
2347 // provider response is rejected by `cost_status::report`.
2348 let _settled_old_cost_scope = crate::cost_status::close_current_scope();
2349 *config = *restored_route.config;
2350 app.api_messages = crate::runtime_handoff::project_messages_for_restore(&session.messages);
2351 app.clear_history();
2352 app.tool_cells.clear();
2353 app.tool_details_by_cell.clear();
2354 app.active_cell = None;
2355 app.active_tool_details.clear();
2356 app.active_tool_entry_completed_at.clear();
2357 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
2358 app.exploring_cell = None;
2359 app.exploring_entries.clear();
2360 app.ignored_tool_calls.clear();
2361 app.pending_tool_uses.clear();
2362 app.last_exec_wait_command = None;
2363 let messages = app.api_messages.clone();
2364 let mut message_to_cell = std::collections::HashMap::new();
2365 for (message_index, msg) in messages.iter().enumerate() {
2366 let mut cells = history_cells_from_message(msg);
2367 if msg.role == "user"
2368 && session
2369 .context_references
2370 .iter()
2371 .any(|record| record.message_index == message_index)
2372 {
2373 for cell in &mut cells {
2374 if let HistoryCell::User { content } = cell {
2375 *content = compact_user_context_display(content);
2376 }
2377 }
2378 }
2379 let base = app.history.len();
2380 if msg.role == "user"
2381 && let Some(offset) = cells
2382 .iter()
2383 .position(|cell| matches!(cell, HistoryCell::User { .. }))
2384 {
2385 message_to_cell.insert(message_index, base + offset);
2386 }
2387 app.extend_history(cells);
2388 }
2389 app.sync_context_references_from_session(&session.context_references, &message_to_cell);
2390 app.mark_history_updated();
2391 app.viewport.transcript_selection.clear();
2392 restore_loaded_session_provider(app, config, provider_identity);
2393 // Session records do not own a reasoning preference. `set_model_selection`
2394 // restores the raw explicit global preference for Auto (or releases an
2395 // implicit fixed-route default) instead of reusing normalized live state.
2396 app.set_model_selection(session.metadata.model.clone());
2397 if app.auto_model
2398 && let Some(saved) = session.last_auto_route.as_ref()
2399 && !saved.provider_identity.trim().is_empty()
2400 && !saved.model.trim().is_empty()
2401 {
2402 app.last_effective_provider = Some(saved.provider);
2403 app.last_effective_provider_identity = Some(saved.provider_identity.clone());
2404 app.last_effective_model = Some(saved.model.clone());
2405 app.last_auto_route_receipt = Some(saved.receipt.clone());
2406 app.last_effective_reasoning_effort = saved.effective_reasoning_effort.map(Into::into);
2407 }
2408 resolve_loaded_session_route(app, config);
2409 if !app.auto_model {
2410 let requested = app
2411 .reasoning_effort_preference
2412 .unwrap_or(app.reasoning_effort);
2413 app.reasoning_effort =
2414 requested.normalize_for_route(app.api_provider, &app.active_route_base_url, &app.model);
2415 }
2416 app.provider_models.insert(
2417 app.provider_identity_for_persistence().to_string(),
2418 app.model_selection_for_persistence(),
2419 );
2420 app.update_model_compaction_budget();
2421 apply_workspace_runtime_state(app, config, session.metadata.workspace.clone());
2422 if let Some(mode) = session.metadata.mode.as_deref().and_then(AppMode::parse) {
2423 app.set_mode(mode);
2424 }
2425 app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
2426 app.session.total_conversation_tokens = app.session.total_tokens;
2427 let restored_parent = crate::pricing::CostEstimate {
2428 usd: session.metadata.cost.session_cost_usd,
2429 cny: session.metadata.cost.session_cost_cny,
2430 }
2431 .sanitized();
2432 let restored_background = crate::pricing::CostEstimate {
2433 usd: session.metadata.cost.subagent_cost_usd,
2434 cny: session.metadata.cost.subagent_cost_cny,
2435 }
2436 .sanitized();
2437 app.session.session_cost = restored_parent.usd;
2438 app.session.session_cost_cny = restored_parent.cny;
2439 app.session.subagent_cost = restored_background.usd;
2440 app.session.subagent_cost_cny = restored_background.cny;
2441 app.session.subagent_cost_event_seqs.clear();
2442 // Coverage is restored *with* the money, and the live counters are cleared
2443 // first: whatever the previous session in this process priced is not inside
2444 // the total being loaded, so carrying those counters over would describe the
2445 // wrong total (#4318).
2446 app.reset_cost_coverage();
2447 app.session.cost_priced_turns = session.metadata.cost.priced_turns;
2448 app.session.cost_unpriced_turns = session.metadata.cost.unpriced_turns;
2449 app.session.cost_cny_priced_turns = session.metadata.cost.cny_priced_turns;
2450 app.session.cost_cny_unpriced_turns = session.metadata.cost.cny_unpriced_turns;
2451 app.session.cost_unpriced_reasons = session.metadata.cost.unpriced_reasons.clone();
2452 app.session.cost_cny_unpriced_reasons = session.metadata.cost.cny_unpriced_reasons.clone();
2453 app.session.cost_unpriced_classes = session.metadata.cost.unpriced_classes.clone();
2454 app.session.cost_pricing_provenances = session.metadata.cost.pricing_provenances.clone();
2455 app.session.cost_live_pricing_defects = session.metadata.cost.live_pricing_defects.clone();
2456 app.session.cost_live_pricing_unusable_defects =
2457 session.metadata.cost.live_pricing_unusable_defects.clone();
2458 app.session.cost_route_receipts = session.metadata.cost.route_receipts.clone();
2459 // A pre-coverage session deserializes its new fields from serde defaults,
2460 // which are indistinguishable from "complete total, zero turns". Flag it so
2461 // `/cost` says the coverage is unknown rather than claiming completeness,
2462 // including for an all-zero record.
2463 app.session.cost_coverage_unknown_legacy = session.metadata.cost.coverage_is_legacy_unknown();
2464 // Restore the high-water marks from persisted metadata so the
2465 // monotonic cost guarantee (#244) survives session restarts.
2466 // Take the max with the current totals — old sessions without
2467 // persisted high-water fields deserialise to 0.0 and fall back to
2468 // the restored total with no regression.
2469 let total_restored_usd = session.metadata.cost.total_usd();
2470 let total_restored_cny = session.metadata.cost.total_cny();
2471 let restored_high_water = crate::pricing::CostEstimate {
2472 usd: session.metadata.cost.displayed_cost_high_water_usd,
2473 cny: session.metadata.cost.displayed_cost_high_water_cny,
2474 }
2475 .sanitized();
2476 app.session.displayed_cost_high_water = restored_high_water.usd.max(total_restored_usd);
2477 app.session.displayed_cost_high_water_cny = restored_high_water.cny.max(total_restored_cny);
2478 app.session.last_prompt_tokens = None;
2479 app.session.last_completion_tokens = None;
2480 app.session.last_output_throughput = None;
2481 app.session.last_prompt_cache_hit_tokens = None;
2482 app.session.last_prompt_cache_miss_tokens = None;
2483 app.session.last_reasoning_replay_tokens = None;
2484 // Accumulated token breakdown is per-runtime-session; reset on load.
2485 app.session.reset_token_breakdown();
2486 app.session.turn_cache_history.clear();
2487 // Restore cumulative turn duration so the footer "worked" chip
2488 // persists across session restarts (#2038).
2489 app.cumulative_turn_duration =
2490 std::time::Duration::from_secs(session.metadata.cumulative_turn_secs);
2491 app.current_session_id = Some(session.metadata.id.clone());
2492 app.current_session_metadata = Some(session.metadata.clone());
2493 app.session_artifacts = session.artifacts.clone();
2494 app.session_title = Some(session.metadata.title.clone());
2495 app.workspace_context = None;
2496 app.workspace_context_refreshed_at = None;
2497 if let Some(sp) = session.system_prompt.as_ref() {
2498 app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
2499 } else {
2500 app.system_prompt = None;
2501 }
2502 app.scroll_to_bottom();
2503 Ok(())
2504 }
2505
2506 pub(crate) fn apply_loaded_session_config_snapshot(
2507 app: &mut App,
2508 config: &mut Config,
2509 session: &SavedSession,
2510 mut next_config: Config,
2511 force_engine_respawn: bool,
2512 ) -> Result<bool, String> {
2513 if force_engine_respawn {
2514 // File `/load` supplies a freshly loaded disk snapshot, but the live
2515 // Config also contains CLI and workspace/project overlays that are not
2516 // represented by that file. Refresh the provider registry atomically
2517 // over the effective Config instead of dropping permission controls.
2518 let mut effective_config = config.clone();
2519 effective_config.refresh_provider_routes_from(&next_config);
2520 next_config = effective_config;
2521 }
2522 let previous_provider = app.api_provider;
2523 let previous_provider_identity = app.provider_identity_for_persistence().to_string();
2524 let previous_workspace = app.workspace.clone();
2525 apply_loaded_session(app, &mut next_config, session)?;
2526 // A file load reads a fresh disk snapshot. Even when the route's enum and
2527 // exact identity are unchanged, endpoint, key, headers, TLS, or retry
2528 // settings may have changed. Rebuild from that same validated snapshot so
2529 // compaction and other pre-turn engine work cannot retain the old client.
2530 let respawn = force_engine_respawn
2531 || loaded_session_requires_engine_respawn(
2532 app,
2533 previous_provider,
2534 &previous_provider_identity,
2535 &previous_workspace,
2536 );
2537 *config = next_config;
2538 Ok(respawn)
2539 }
2540
2540 lines RUST