返回 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::observer_hooks::{
7 execute_subagent_observer_hook, subagent_failure_notice,
8 surface_observer_hook_submission_failure,
9 };
10 use super::task_projection::refresh_active_task_panel;
11 use super::*;
12
13 /// Record the model frozen into a child's runtime at spawn time.
14 ///
15 /// This is child-route evidence, not an inference from the parent session. A
16 /// later usage envelope may confirm or replace it with the provider's
17 /// effective route while also adding provider and token facts.
18 pub(crate) fn record_agent_spawned_route(app: &mut App, agent_id: &str, model: &str) {
19 let model =
20 bound_agent_activity_text(&crate::cost_status::sanitize_persisted_route_label(model));
21 app.agent_progress_meta
22 .entry(agent_id.to_string())
23 .or_default()
24 .resolved_model = Some(model).filter(|model| !model.trim().is_empty());
25 }
26
27 /// Apply the normal spawn status first, then submit its observer event.
28 /// Submission diagnostics go to the independent toast queue, so they remain
29 /// visible without replacing the agent's authoritative lifecycle status.
30 pub(crate) fn apply_agent_spawned_status_and_observer(
31 app: &mut App,
32 agent_id: &str,
33 prompt: &str,
34 prompt_summary: &str,
35 ) {
36 let label = app.ensure_agent_label(agent_id);
37 codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::SubagentSpawn);
38 app.push_status_toast_record(
39 StatusToast::new(
40 format!(
41 "{} · {label} · {}",
42 app.tr(MessageId::SubagentsStatusRunning),
43 bound_agent_activity_text(prompt_summary)
44 ),
45 StatusToastLevel::Info,
46 Some(4_000),
47 )
48 .for_event(format!("subagent-start:{agent_id}")),
49 );
50 if let Err(error) =
51 execute_subagent_observer_hook(app, HookEvent::SubagentSpawn, agent_id, "prompt", prompt)
52 {
53 surface_observer_hook_submission_failure(app, error);
54 }
55 }
56
57 /// Completion counterpart to [`apply_agent_spawned_status_and_observer`].
58 pub(crate) fn apply_agent_complete_status_and_observer(
59 app: &mut App,
60 agent_id: &str,
61 result: &str,
62 status: &SubAgentStatus,
63 ) {
64 let label = app.agent_display_label(agent_id);
65 let level = match status {
66 SubAgentStatus::Completed => StatusToastLevel::Success,
67 SubAgentStatus::Failed(_) | SubAgentStatus::BudgetExhausted => StatusToastLevel::Error,
68 SubAgentStatus::Interrupted(_) | SubAgentStatus::Cancelled => StatusToastLevel::Warning,
69 SubAgentStatus::Running => StatusToastLevel::Info,
70 };
71 let failure = subagent_failure_notice(result);
72 let detail = failure.as_deref().unwrap_or(result);
73 let message = format!(
74 "{} · {label} · {}",
75 app.tr(notifications::subagent_terminal_label(status)),
76 bound_agent_activity_text(detail)
77 );
78 if level == StatusToastLevel::Error {
79 app.set_sticky_status(message, level, Some(App::STICKY_ERROR_TTL_MS));
80 } else {
81 app.push_status_toast_record(
82 StatusToast::new(message, level, Some(5_000))
83 .for_event(format!("subagent-terminal:{agent_id}")),
84 );
85 }
86 if let Err(error) =
87 execute_subagent_observer_hook(app, HookEvent::SubagentComplete, agent_id, "result", result)
88 {
89 surface_observer_hook_submission_failure(app, error);
90 }
91 }
92
93 pub(crate) fn apply_coordination_detail_projection(
94 app: &mut App,
95 projection: crate::tools::subagent::CoordinationDetailProjection,
96 ) {
97 // §2.6: when this process does not own the workspace coordination flock,
98 // say so on the sticky status strip. A silent "running (543s)" row on a
99 // settled turn is a lie; surface the lock loss the same way we surface
100 // other session hazards.
101 //
102 // Exception: a same-process handover. A model/provider switch spawns the
103 // new engine before the old engine's manager has dropped the flock, and
104 // flock treats the second fd in this same process as a conflict. That
105 // state self-heals on the next projection retry (#5036), and a 30-second
106 // warning blaming "another Codewhale process" would be false (owner
107 // report, 2026-08-04) — so it stays off the sticky strip.
108 if !projection.process_lock_held {
109 let note = projection
110 .process_lock_note
111 .as_deref()
112 .unwrap_or("another Codewhale process owns delegated coordination for this workspace");
113 let same_process_handover =
114 note.contains(crate::tools::subagent::COORDINATION_SAME_PROCESS_HANDOVER);
115 // The strip is one row. The old copy opened with the diagnosis
116 // ("Delegated coordination unavailable — ") and buried the cause
117 // behind a `{note}` carrying a pid, an absolute workspace path, and an
118 // errno, so a truncated strip showed `Delegated coordination
119 // unavailable — an…` and taught the user nothing. Lead with the fact
120 // that explains it — a second session is open here — and leave the pid
121 // and path to the coordination detail view, which already renders
122 // `process_lock_note` in full.
123 let message = if note.contains(crate::tools::subagent::COORDINATION_LOCK_TIMEOUT_MARKER) {
124 "Timed out claiming delegated coordination for this workspace — job rows still settle locally.".to_string()
125 } else {
126 "Another Codewhale session in this workspace owns delegated coordination — job rows still settle locally.".to_string()
127 };
128 // Demoted from sticky 30s to transient 5s — two sessions in same workspace
129 // should not feel broken; job rows still settle locally. The detail view
130 // still shows the full pid/path via `process_lock_note`.
131 let already = app
132 .status_toasts
133 .iter()
134 .any(|toast| toast.text.contains("delegated coordination"));
135 if !already && !same_process_handover {
136 app.push_status_toast(
137 message,
138 crate::tui::app::StatusToastLevel::Info,
139 Some(5_000),
140 );
141 }
142 }
143 app.coordination_detail = Some(projection);
144 }
145
146 pub(crate) fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
147 rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Files);
148 }
149
150 pub(crate) fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) {
151 // Ctrl+Alt+0 toggles the rail off and back to the default bottom
152 // placement. Plain Alt+0 is unbound: it used to select the retired
153 // auto-collapse mode.
154 if modifiers.contains(KeyModifiers::CONTROL) {
155 if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
156 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Bottom;
157 app.status_message = Some("Workbar: bottom placement".to_string());
158 } else {
159 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
160 app.status_message = Some("Workbar is off".to_string());
161 }
162 app.needs_redraw = true;
163 }
164 }
165
166 pub(crate) fn apply_picker_session_rename_to_active_app(
167 app: &mut App,
168 metadata: crate::session_manager::SessionMetadata,
169 ) -> bool {
170 if app.current_session_id.as_deref() != Some(metadata.id.as_str()) {
171 return false;
172 }
173 app.session_title = Some(metadata.title.clone());
174 app.current_session_metadata = Some(metadata);
175 true
176 }
177
178 /// Translate an `EngineEvent::Error` into UI state updates.
179 ///
180 /// The engine's `recoverable` flag (mirrored on `ErrorEnvelope`) decides
181 /// whether the session flips into offline mode: stream stalls, chunk
182 /// timeouts, transient network errors, and rate-limit/server hiccups arrive
183 /// recoverable and must NOT flip into offline. Hard failures (auth, billing,
184 /// invalid request) arrive non-recoverable; those flip offline so subsequent
185 /// messages get queued instead of silently lost mid-flight.
186 ///
187 /// `severity` drives transcript color: red for `Error`/`Critical`, amber for
188 /// `Warning`, dim for `Info`.
189 pub(crate) fn apply_engine_error_to_app(
190 app: &mut App,
191 envelope: crate::error_taxonomy::ErrorEnvelope,
192 ) {
193 let recoverable = envelope.recoverable;
194 let message = envelope.message.clone();
195 let severity = envelope.severity;
196 let turn_was_in_progress =
197 app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"));
198 // A recoverable error can precede tool decisions in the same turn. Keep
199 // routing those events until TurnComplete; marking the UI idle here drops
200 // ApprovalRequired and leaves the engine waiting for an invisible decision.
201 // An idle or locally cancelled turn must never be reactivated by an error.
202 let turn_remains_active =
203 recoverable && turn_was_in_progress && !app.suppress_stream_events_until_turn_complete;
204 streaming_thinking::finalize_current(app);
205 if turn_was_in_progress {
206 app.finalize_streaming_assistant_as_interrupted();
207 app.finalize_active_cell_as_interrupted();
208 if !turn_remains_active {
209 app.runtime_turn_status = Some("failed".to_string());
210 }
211 }
212 app.streaming_state.reset();
213 app.streaming_message_index = None;
214 app.streaming_thinking_active_entry = None;
215
216 // #455 (observer-only): fire `on_error` hooks so operators can
217 // page on auth / billing / invalid-request failures without
218 // tailing the audit log. Read-only — the hook can react but not
219 // suppress the error from reaching the transcript. Fast-path
220 // skip when no hooks configured.
221 if app
222 .hooks
223 .has_hooks_for_event(crate::hooks::HookEvent::OnError)
224 {
225 let context = app.base_hook_context().with_error(&message);
226 if let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context) {
227 surface_observer_hook_submission_failure(app, error);
228 }
229 }
230
231 app.add_message(HistoryCell::Error {
232 message: message.clone(),
233 severity,
234 });
235 app.is_loading = turn_remains_active;
236 if !turn_remains_active {
237 app.dispatch_started_at = None;
238 }
239 app.turn_error_posted = true;
240 if matches!(
241 envelope.category,
242 crate::error_taxonomy::ErrorCategory::Authentication
243 ) && app.api_key_env_only
244 {
245 app.offline_mode = true;
246 app.onboarding_needs_api_key = true;
247 app.onboarding = OnboardingState::Provider;
248 let provider = app.api_provider;
249 let config_path = match crate::config::resolve_load_config_path(app.config_path.clone()) {
250 Ok(Some(path)) => path.display().to_string(),
251 Ok(None) => "~/.codewhale/config.toml".to_string(),
252 Err(error) => error.to_string(),
253 };
254 app.push_status_toast(
255 tr(app.ui_locale, MessageId::OnboardApiKeyRejectedEnv)
256 .replace("{provider}", provider.as_str())
257 .replace("{env}", &provider.env_vars_label())
258 .replace("{path}", &config_path),
259 StatusToastLevel::Error,
260 Some(App::STICKY_ERROR_TTL_MS),
261 );
262 return;
263 }
264 if recoverable
265 && matches!(
266 envelope.category,
267 crate::error_taxonomy::ErrorCategory::Network
268 | crate::error_taxonomy::ErrorCategory::RateLimit
269 | crate::error_taxonomy::ErrorCategory::Timeout
270 )
271 && app.advance_fallback(message.clone()).is_some()
272 {
273 let position = app.fallback_chain_position().unwrap_or(0);
274 let total = app.fallback_chain_len();
275 app.push_status_toast(
276 app.tr(MessageId::NotificationProviderFallback)
277 .replace("{provider}", app.api_provider.as_str())
278 .replace("{position}", &position.to_string())
279 .replace("{total}", &total.saturating_sub(1).to_string()),
280 StatusToastLevel::Warning,
281 Some(8_000),
282 );
283 return;
284 }
285 if !recoverable {
286 app.offline_mode = true;
287 }
288 // Error is already in the transcript as HistoryCell::Error above;
289 // don't emit a redundant status_message that would become a sticky
290 // toast in the footer — that duplicates the transcript entry.
291 }
292
293 /// Apply the gate result on the event loop. Returns `true` when dispatch may
294 /// continue; a denial leaves the original message out of history/model input.
295 pub(crate) fn apply_message_submit_outcome(
296 app: &mut App,
297 message: &mut QueuedMessage,
298 outcome: crate::hooks::MessageSubmitOutcome,
299 ) -> bool {
300 if let Some(warning) = outcome.warning() {
301 app.status_message = Some(warning.to_string());
302 }
303 match outcome {
304 crate::hooks::MessageSubmitOutcome::Unchanged { .. } => true,
305 crate::hooks::MessageSubmitOutcome::Replaced { text, .. } => {
306 message.display = text;
307 true
308 }
309 crate::hooks::MessageSubmitOutcome::Blocked { reason } => {
310 app.status_message = Some(reason);
311 false
312 }
313 }
314 }
315
316 fn visible_goal_as_durable(
317 app: &App,
318 ) -> Result<Option<crate::session_manager::SessionGoalState>, String> {
319 let Some(objective) = app.goal.objective.as_deref() else {
320 return Ok(None);
321 };
322 let elapsed_seconds = app
323 .goal
324 .started_at
325 .map(|started| started.elapsed().as_secs())
326 .unwrap_or(app.goal.time_used_seconds)
327 .max(app.goal.time_used_seconds);
328 crate::session_manager::SessionGoalState::from_runtime(&GoalSnapshot {
329 objective: Some(objective.to_string()),
330 status: app.goal.status.as_str().to_string(),
331 token_budget: app.goal.token_budget,
332 tokens_used: app.goal.tokens_used,
333 time_used_seconds: app.goal.time_used_seconds,
334 continuation_count: app.goal.continuation_count,
335 elapsed_seconds: Some(elapsed_seconds),
336 pause_reason: app.goal.pause_reason,
337 ..Default::default()
338 })
339 .map_err(|error| error.to_string())
340 }
341
342 fn desired_goal_state(
343 app: &App,
344 intent: &GoalControlIntent,
345 ) -> Result<Option<crate::session_manager::SessionGoalState>, String> {
346 let mut base = if app.pending_goal_controls.is_empty() {
347 match app.last_known_goal_state.clone() {
348 Some(goal) => Some(goal),
349 None => visible_goal_as_durable(app)?,
350 }
351 } else {
352 // Accepted controls compose over the latest durable target, not the
353 // older visible projection that is still waiting on GoalUpdated.
354 app.last_known_goal_state.clone()
355 };
356 match intent {
357 GoalControlIntent::SetStatus { clear: true, .. } => Ok(None),
358 GoalControlIntent::SetStatus {
359 status,
360 clear: false,
361 } => {
362 let goal = base
363 .as_mut()
364 .ok_or_else(|| "No goal is available for this control.".to_string())?;
365 goal.status = match status {
366 GoalStatus::Active => crate::session_manager::SessionGoalStatus::Active,
367 GoalStatus::Paused => crate::session_manager::SessionGoalStatus::Paused,
368 GoalStatus::Complete => crate::session_manager::SessionGoalStatus::Complete,
369 GoalStatus::Blocked => crate::session_manager::SessionGoalStatus::Blocked,
370 };
371 if *status == GoalStatus::Active {
372 goal.goal_id = Some(uuid::Uuid::new_v4().to_string());
373 goal.last_gap_fingerprint = None;
374 goal.repeated_gap_count = 0;
375 goal.last_gap_pass = None;
376 }
377 goal.pause_reason = (*status == GoalStatus::Paused)
378 .then_some(crate::tools::goal::GoalPauseReason::User);
379 Ok(base)
380 }
381 GoalControlIntent::SetObjective {
382 objective,
383 token_budget,
384 } => crate::session_manager::SessionGoalState::from_runtime(&GoalSnapshot {
385 goal_id: Some(uuid::Uuid::new_v4().to_string()),
386 objective: Some(objective.clone()),
387 status: GoalStatus::Active.as_str().to_string(),
388 token_budget: *token_budget,
389 elapsed_seconds: Some(0),
390 ..Default::default()
391 })
392 .map_err(|error| error.to_string()),
393 }
394 }
395
396 fn persist_accepted_goal_state(
397 app: &mut App,
398 desired: Option<&crate::session_manager::SessionGoalState>,
399 ) -> Result<(), String> {
400 let manager = SessionManager::default_location()
401 .map_err(|error| format!("could not open the session store: {error}"))?;
402 if app.current_session_id.is_none() {
403 let session = build_session_snapshot(app, &manager)?;
404 let session_id = session.metadata.id.clone();
405 if !persistence_actor::try_persist(PersistRequest::SaveCheckpoint { session }) {
406 return Err("the persistence worker is unavailable".to_string());
407 }
408 app.current_session_id = Some(session_id);
409 }
410 let session_id = app
411 .current_session_id
412 .as_deref()
413 .ok_or_else(|| "session id is not established".to_string())?;
414 manager
415 .save_session_goal(session_id, desired)
416 .map_err(|error| error.to_string())
417 }
418
419 fn goal_control_op(intent: &GoalControlIntent, goal_id: Option<String>) -> Op {
420 match intent {
421 GoalControlIntent::SetStatus { status, clear } => Op::SetGoalStatus {
422 goal_id,
423 status: *status,
424 clear: *clear,
425 },
426 GoalControlIntent::SetObjective {
427 objective,
428 token_budget,
429 } => Op::SetGoalObjective {
430 goal_id,
431 objective: objective.clone(),
432 token_budget: *token_budget,
433 },
434 }
435 }
436
437 /// Retry accepted goal controls without ever awaiting mailbox capacity on the
438 /// input loop. FIFO order is retained until each authoritative receipt lands.
439 pub(crate) fn flush_pending_goal_controls(app: &mut App, engine_handle: &EngineHandle) -> bool {
440 for pending in &mut app.pending_goal_controls {
441 if pending.dispatched {
442 continue;
443 }
444 if engine_handle
445 .try_send(goal_control_op(&pending.intent, pending.goal_id.clone()))
446 .is_err()
447 {
448 return engine_handle.tx_op.is_closed();
449 }
450 pending.dispatched = true;
451 }
452 false
453 }
454
455 fn goal_control_matches(
456 intent: &GoalControlIntent,
457 durable: Option<&crate::session_manager::SessionGoalState>,
458 ) -> bool {
459 match intent {
460 GoalControlIntent::SetStatus { clear: true, .. } => durable.is_none(),
461 GoalControlIntent::SetStatus {
462 status,
463 clear: false,
464 } => durable.is_some_and(|goal| {
465 goal.status
466 == match status {
467 GoalStatus::Active => crate::session_manager::SessionGoalStatus::Active,
468 GoalStatus::Paused => crate::session_manager::SessionGoalStatus::Paused,
469 GoalStatus::Complete => crate::session_manager::SessionGoalStatus::Complete,
470 GoalStatus::Blocked => crate::session_manager::SessionGoalStatus::Blocked,
471 }
472 }),
473 GoalControlIntent::SetObjective {
474 objective,
475 token_budget,
476 } => durable.is_some_and(|goal| {
477 goal.objective == *objective
478 && goal.status == crate::session_manager::SessionGoalStatus::Active
479 && goal.token_budget == *token_budget
480 }),
481 }
482 }
483
484 fn accept_goal_control(app: &mut App, engine_handle: &EngineHandle, intent: GoalControlIntent) {
485 let desired = match desired_goal_state(app, &intent) {
486 Ok(desired) => desired,
487 Err(error) => {
488 surface_goal_persistence_failure(app, &error);
489 return;
490 }
491 };
492 if let Err(error) = persist_accepted_goal_state(app, desired.as_ref()) {
493 surface_goal_persistence_failure(app, &error);
494 return;
495 }
496
497 if matches!(
498 intent,
499 GoalControlIntent::SetStatus {
500 status: GoalStatus::Complete,
501 clear: false
502 }
503 ) {
504 crate::audit::log_sensitive_event(
505 "goal.user_completed",
506 serde_json::json!({ "accepted": true }),
507 );
508 }
509 app.last_known_goal_state = desired;
510 app.pending_goal_controls.push_back(PendingGoalControl {
511 goal_id: app
512 .last_known_goal_state
513 .as_ref()
514 .and_then(|goal| goal.goal_id.clone()),
515 intent,
516 dispatched: false,
517 });
518 let runtime_closed = flush_pending_goal_controls(app, engine_handle);
519 app.add_message(HistoryCell::System {
520 content: app.tr(MessageId::GoalControlAccepted).to_string(),
521 });
522 if runtime_closed {
523 app.push_status_toast(
524 app.tr(MessageId::GoalControlRuntimeUnavailable).to_string(),
525 StatusToastLevel::Warning,
526 None,
527 );
528 }
529 }
530
531 pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot) -> bool {
532 let durable_goal = match crate::session_manager::SessionGoalState::from_runtime(snapshot) {
533 Ok(goal) => goal,
534 Err(error) => {
535 tracing::warn!("ignoring invalid runtime goal snapshot: {error}");
536 return false;
537 }
538 };
539 let pending_desired = app.last_known_goal_state.clone();
540 let matched_pending = app.pending_goal_controls.front().is_some_and(|pending| {
541 pending.dispatched
542 && pending.goal_id.as_deref().is_none_or(|id| {
543 Some(id)
544 == durable_goal
545 .as_ref()
546 .and_then(|goal| goal.goal_id.as_deref())
547 })
548 && goal_control_matches(&pending.intent, durable_goal.as_ref())
549 });
550 // Accepted controls own the durable target until their exact revision's
551 // receipt arrives. An earlier pass cannot restore pre-resume stall state.
552 if !app.pending_goal_controls.is_empty() && !matched_pending {
553 return false;
554 }
555 let durable_changed = app.last_known_goal_state != durable_goal;
556 if matched_pending {
557 app.pending_goal_controls.pop_front();
558 }
559 // An explicit engine-side clear is represented by the one canonical empty
560 // state emitted by GoalState::snapshot. Require both fields so a malformed
561 // objective-less Active/Blocked update cannot erase valid visible state.
562 if snapshot.objective.is_none() && snapshot.status.trim() == "none" {
563 let changed = app.goal.objective.is_some()
564 || app.goal.token_budget.is_some()
565 || app.goal.tokens_used != 0
566 || app.goal.time_used_seconds != 0
567 || app.goal.continuation_count != 0
568 || app.goal.started_at.is_some()
569 || app.goal.finished_at.is_some()
570 || app.goal.status != GoalStatus::default();
571 app.goal = crate::tui::app::HostGoalState::default();
572 app.last_known_goal_state = if app.pending_goal_controls.is_empty() {
573 None
574 } else {
575 pending_desired
576 };
577 return changed || matched_pending || durable_changed;
578 }
579
580 let Some(objective) = snapshot
581 .objective
582 .as_deref()
583 .map(str::trim)
584 .filter(|objective| !objective.is_empty())
585 else {
586 tracing::warn!(
587 "ignoring objective-less runtime goal snapshot with non-clear status: {}",
588 snapshot.status
589 );
590 return false;
591 };
592 let Some(status) = goal_status_from_snapshot(snapshot) else {
593 tracing::warn!("ignoring unknown runtime goal status: {}", snapshot.status);
594 return false;
595 };
596 let verdict = status;
597 let objective_changed = app.goal.objective.as_deref() != Some(objective);
598 let progress_changed = app.goal.progress != snapshot.progress;
599 let changed = objective_changed
600 || app.goal.token_budget != snapshot.token_budget
601 || app.goal.tokens_used != snapshot.tokens_used
602 || app.goal.time_used_seconds != snapshot.time_used_seconds
603 || app.goal.continuation_count != snapshot.continuation_count
604 || app.goal.pause_reason != snapshot.pause_reason
605 || progress_changed
606 || app.goal.status != verdict;
607 if !changed {
608 app.last_known_goal_state = if app.pending_goal_controls.is_empty() {
609 durable_goal
610 } else {
611 pending_desired
612 };
613 return matched_pending || durable_changed;
614 }
615
616 // The runtime introduced a new active objective (the model called
617 // `create_goal`, or a restored session carried one): say so once, in one
618 // line, so the user knows a persistent goal is now driving turns and how
619 // to stop it. `/goal <objective>` sets the objective before this snapshot lands,
620 // so a user-declared goal does not repeat its own receipt.
621 if objective_changed && verdict == GoalStatus::Active {
622 // Operate set it from the prompt (or the model did while operating);
623 // the objective is the prompt the user just typed, so the receipt
624 // says what Operate will do with it instead of echoing it.
625 let content = if app.mode == AppMode::Operate {
626 app.tr(codewhale_localization::MessageId::GoalReceiptSetOperate)
627 .into_owned()
628 } else {
629 app.tr(codewhale_localization::MessageId::GoalReceiptSet)
630 .replace("{objective}", objective)
631 };
632 app.add_message(crate::tui::history::HistoryCell::System { content });
633 }
634 // A fresh reported-progress receipt reads like the model's own status
635 // line: percent with a bar, then the optional now/next lines it wrote.
636 // Paused/complete goals keep their last report silent — the lifecycle
637 // receipt already spoke.
638 if progress_changed
639 && verdict == GoalStatus::Active
640 && let Some(progress) = snapshot.progress.as_ref()
641 {
642 let mut content = app
643 .tr(codewhale_localization::MessageId::GoalProgressReceipt)
644 .replace("{percent}", &progress.percent.to_string())
645 .replace(
646 "{bar}",
647 &crate::tools::goal::goal_progress_bar(progress.percent),
648 );
649 if let Some(now) = progress.now.as_deref() {
650 content.push('\n');
651 content.push_str(
652 &app.tr(codewhale_localization::MessageId::GoalProgressNow)
653 .replace("{note}", now),
654 );
655 }
656 if let Some(next) = progress.next.as_deref() {
657 content.push('\n');
658 content.push_str(
659 &app.tr(codewhale_localization::MessageId::GoalProgressNext)
660 .replace("{note}", next),
661 );
662 }
663 app.add_message(crate::tui::history::HistoryCell::System { content });
664 }
665 app.goal.progress = snapshot.progress.clone();
666 app.goal.objective = Some(objective.to_string());
667 app.goal.token_budget = snapshot.token_budget;
668 app.goal.tokens_used = snapshot.tokens_used;
669 app.goal.time_used_seconds = snapshot.time_used_seconds;
670 app.goal.continuation_count = snapshot.continuation_count;
671 app.goal.pause_reason = snapshot.pause_reason;
672 app.goal.status = verdict;
673 if objective_changed || app.goal.started_at.is_none() {
674 let now = Instant::now();
675 let elapsed = std::time::Duration::from_secs(snapshot.elapsed_seconds.unwrap_or_default());
676 app.goal.started_at = now.checked_sub(elapsed).or(Some(now));
677 }
678 // Freeze the elapsed timer the first time a goal leaves the active state.
679 // Paused (Wounded) goals freeze too — usage snapshots keep arriving while
680 // paused, and clearing here would silently un-freeze a timer the user just
681 // paused (matching close_hunt, which records the pause instant). Only an
682 // explicit resume back to Hunting re-arms the timer.
683 match verdict {
684 GoalStatus::Complete | GoalStatus::Blocked | GoalStatus::Paused => {
685 if app.goal.finished_at.is_none() {
686 app.goal.finished_at = Some(Instant::now());
687 }
688 }
689 GoalStatus::Active => app.goal.finished_at = None,
690 }
691 app.last_known_goal_state = if app.pending_goal_controls.is_empty() {
692 durable_goal
693 } else {
694 pending_desired
695 };
696 true
697 }
698
699 /// Apply an explicit mode selection from a user shortcut (Alt+A/P/Y).
700 ///
701 /// Uses `select_mode`, not `set_mode`, so an explicitly chosen mode is also the
702 /// startup default next launch – matching the Tab cycle and hotbar paths.
703 pub(crate) async fn apply_mode_update(
704 app: &mut App,
705 engine_handle: &EngineHandle,
706 config: &Config,
707 mode: AppMode,
708 ) -> bool {
709 let outcome = app.select_mode(mode);
710 app.report_mode_selection(mode, outcome);
711 if mode == AppMode::Operate {
712 present_operate_board(app, config).await;
713 }
714 if outcome.changed_live_state() {
715 sync_mode_update(app, engine_handle).await;
716 true
717 } else {
718 false
719 }
720 }
721
722 /// Apply the legacy YOLO shortcut (Alt+Y): a permission change, not a mode
723 /// change. Same persist/report/sync contract as [`apply_mode_update`]; the
724 /// startup default written is the mode actually installed (Act).
725 pub(crate) async fn apply_yolo_compat_update(
726 app: &mut App,
727 engine_handle: &EngineHandle,
728 _config: &Config,
729 ) -> bool {
730 let outcome = app.select_yolo_compat();
731 app.report_mode_selection(AppMode::Agent, outcome);
732 if outcome.changed_live_state() {
733 sync_mode_update(app, engine_handle).await;
734 true
735 } else {
736 false
737 }
738 }
739
740 /// Entering Operate attaches to the recorded operation (a fresh one only
741 /// when none exists or the last was cancelled), shows the localized lead
742 /// plan, and keeps always-on mode durable by reinstalling the hourly lead
743 /// keepalive bound to this workspace. Burn rate is optional; default is
744 /// unbounded.
745 async fn present_operate_board(app: &mut App, config: &Config) {
746 let store = match crate::operate::OperationStore::open(crate::operate::default_operate_dir()) {
747 Ok(store) => store,
748 Err(error) => {
749 app.add_message(crate::tui::history::HistoryCell::System {
750 content: format!("Operate store unavailable: {error}"),
751 });
752 return;
753 }
754 };
755 let Some(automations) = app
756 .runtime_services
757 .automations
758 .as_ref()
759 .map(std::sync::Arc::clone)
760 else {
761 app.add_message(crate::tui::history::HistoryCell::System {
762 content: "Operate keep-alive not installed: automation service unavailable".to_string(),
763 });
764 return;
765 };
766 let model = app.model_selection_for_persistence();
767 let identity = match config.resolve_persisted_provider_identity(
768 Some(app.api_provider.as_str()),
769 app.provider_id_for_persistence(),
770 ) {
771 Ok(identity) => identity,
772 Err(error) => {
773 app.add_message(crate::tui::history::HistoryCell::System {
774 content: format!("Operate keep-alive not installed: {error}"),
775 });
776 return;
777 }
778 };
779 let (lead_model, credentials) = {
780 let manager = automations.lock().await;
781 match crate::operate::keepalive_readiness(&manager, config, Some((&identity, &model))) {
782 Ok(credentials) => credentials,
783 Err(error) => {
784 app.add_message(crate::tui::history::HistoryCell::System {
785 content: format!("Operate keep-alive not installed: {error}"),
786 });
787 return;
788 }
789 }
790 };
791 let operation = match crate::operate::attach_or_start_operation(
792 &store,
793 &app.workspace,
794 None,
795 None,
796 credentials,
797 &lead_model,
798 ) {
799 Ok(mut operation) => {
800 if credentials && !operation.direction.is_empty() && operation.lead_plan.is_none() {
801 operation.plan_from_direction();
802 if let Err(error) = store.save(&operation) {
803 app.add_message(crate::tui::history::HistoryCell::System {
804 content: format!("Operate plan not saved: {error}"),
805 });
806 }
807 }
808 operation
809 }
810 Err(error) => {
811 app.add_message(crate::tui::history::HistoryCell::System {
812 content: format!("Operate did not start: {error}"),
813 });
814 return;
815 }
816 };
817 // Always-on is durable only if the keepalive automation exists: entering
818 // Operate (re)installs it for this workspace, kicking an immediate
819 // lead-plan step when the attached operation still needs one.
820 let needs_lead_plan = !operation
821 .lead_plan
822 .as_ref()
823 .is_some_and(|plan| !plan.slices.is_empty());
824 {
825 let manager = automations.lock().await;
826 if let Err(error) = crate::operate::upsert_keepalive(
827 &manager,
828 &app.workspace,
829 needs_lead_plan,
830 config,
831 Some((&identity, &model)),
832 ) {
833 app.add_message(crate::tui::history::HistoryCell::System {
834 content: format!("Operate keep-alive not installed: {error}"),
835 });
836 }
837 }
838 app.add_message(crate::tui::history::HistoryCell::System {
839 content: crate::operate::render_plan_board_locale(&operation, app.ui_locale),
840 });
841 }
842
843 pub(crate) async fn apply_model_and_compaction_update(
844 engine_handle: &EngineHandle,
845 compaction: crate::compaction::CompactionConfig,
846 mode: AppMode,
847 route_limits: Option<codewhale_config::route::RouteLimits>,
848 ) {
849 let _ = engine_handle
850 .send(Op::SetModel {
851 model: compaction.model.clone(),
852 mode,
853 route_limits,
854 })
855 .await;
856 let _ = engine_handle
857 .send(Op::SetCompaction { config: compaction })
858 .await;
859 }
860
861 /// Apply the choice made in the `/model` picker (#39): mutate App state so
862 /// the next turn uses the new model/effort, push the change to the running
863 /// engine via `Op::SetModel`/`Op::SetCompaction`, and surface a one-line
864 /// status describing what changed. Startup persistence is intentionally owned
865 /// by the picker's explicit Shift+D action in the view-event handler.
866 // The model/effort transition needs both the previous and next model+effort
867 // plus the engine, app, and config handles; bundling them into a struct here
868 // would only obscure a straightforward orchestration step.
869 #[allow(clippy::too_many_arguments)]
870 pub(crate) async fn apply_model_picker_choice(
871 app: &mut App,
872 engine_handle: &mut EngineHandle,
873 config: &mut Config,
874 model: String,
875 target_provider: Option<ApiProvider>,
876 target_provider_id: Option<String>,
877 effort: crate::reasoning_preference::ReasoningEffort,
878 previous_model: String,
879 previous_effort: crate::reasoning_preference::ReasoningEffort,
880 save_as_startup_default: bool,
881 ) {
882 if app.reject_setting_change_while_busy(
883 codewhale_localization::MessageId::SettingSubjectModelAndThinking,
884 ) {
885 note_startup_default_not_saved(app, save_as_startup_default);
886 return;
887 }
888 let target_provider = target_provider.unwrap_or(app.api_provider);
889 let target_identity = if target_provider == ApiProvider::Custom {
890 target_provider_id.unwrap_or_else(|| config.provider_identity_for(target_provider))
891 } else {
892 target_provider.as_str().to_string()
893 };
894 let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
895 let preserve_auto_effort =
896 app.reasoning_effort_preference.is_some() || effort != previous_effort;
897 if target_provider != app.api_provider
898 || target_identity != app.provider_identity_for_persistence()
899 {
900 config.provider = Some(target_identity.clone());
901 switch_provider(
902 app,
903 engine_handle,
904 config,
905 target_provider,
906 (!model_is_auto).then_some(model.clone()),
907 )
908 .await;
909 if app.api_provider != target_provider
910 || app.provider_identity_for_persistence() != target_identity
911 {
912 // The switch was refused (missing credentials, bad route). The
913 // live route is still the old one, so persisting it as the startup
914 // default would silently pin the route the user just tried to leave.
915 note_startup_default_not_saved(app, save_as_startup_default);
916 return;
917 }
918 if !model_is_auto {
919 apply_picker_effort_choice(app, engine_handle, effort, previous_effort).await;
920 if save_as_startup_default {
921 app.status_message = Some(app.save_live_route_as_startup_default());
922 }
923 return;
924 }
925 }
926
927 let model_changed = model != previous_model || app.auto_model != model_is_auto;
928 let mut resolved_model = model.clone();
929 let mut route_base_url = config.active_route_base_url();
930 if !model_is_auto {
931 match crate::route_runtime::resolve_runtime_route(config, app.api_provider, Some(&model)) {
932 Ok(resolution) => {
933 resolved_model = resolution.candidate.wire_model_id().as_str().to_string();
934 route_base_url = resolution.candidate.endpoint().base_url.clone();
935 if model_changed {
936 app.set_active_context_window_override(config, app.api_provider);
937 app.set_active_route_resolution(
938 route_base_url.clone(),
939 resolution.candidate.limits(),
940 resolution.context_window.source,
941 );
942 }
943 }
944 Err(reason) => {
945 app.status_message = Some(reason);
946 note_startup_default_not_saved(app, save_as_startup_default);
947 return;
948 }
949 }
950 } else if model_changed {
951 app.set_active_context_window_override(config, app.api_provider);
952 app.active_route_limits = app.context_window_override_limits();
953 app.active_route_base_url = route_base_url.clone();
954 app.active_context_window_source = app
955 .configured_context_window_for(&app.model)
956 .map(|resolution| resolution.source)
957 .unwrap_or(crate::route_runtime::ContextWindowSource::Fallback);
958 }
959
960 let effective_effort = if model_is_auto {
961 effort
962 } else {
963 effort.normalize_for_route(app.api_provider, &route_base_url, &resolved_model)
964 };
965 let effort_changed = effort != previous_effort;
966
967 if model_changed {
968 app.set_model_selection(resolved_model.clone());
969 let provider_identity = app.provider_identity_for_persistence().to_string();
970 app.provider_models
971 .insert(provider_identity.clone(), resolved_model.clone());
972 app.enable_provider_model(&provider_identity, &resolved_model);
973 app.clear_model_scoped_telemetry();
974 }
975 let preference_changed = if model_is_auto && !preserve_auto_effort {
976 app.reasoning_effort_preference.take().is_some()
977 } else {
978 let changed = app.reasoning_effort_preference != Some(effort);
979 app.reasoning_effort_preference = Some(effort);
980 changed
981 };
982 let live_effort_changed = effective_effort != app.reasoning_effort;
983 if !model_is_auto || preserve_auto_effort {
984 app.reasoning_effort = effective_effort;
985 } else {
986 app.reasoning_effort = ReasoningEffort::Auto;
987 }
988 if live_effort_changed || preference_changed {
989 app.invalidate_route_receipts_for_reasoning_change();
990 }
991 if model_changed || live_effort_changed || preference_changed {
992 app.update_model_compaction_budget();
993 }
994
995 // A model pick is session-local by default. Keep the exact live route in
996 // memory and offer an explicit save decision; only Shift+D in the picker
997 // writes a startup default.
998 let route_provider = app.provider_identity_for_persistence().to_string();
999 app.note_session_route_change(&route_provider, &resolved_model);
1000
1001 if model_changed {
1002 apply_model_and_compaction_update(
1003 engine_handle,
1004 app.compaction_config(),
1005 app.mode,
1006 app.active_route_limits,
1007 )
1008 .await;
1009 }
1010
1011 let model_summary = if model_is_auto {
1012 "auto (per-turn model)".to_string()
1013 } else {
1014 resolved_model.clone()
1015 };
1016 let previous_effort_summary = previous_effort.display_label_for_provider(app.api_provider);
1017 let applied_effort = app.reasoning_effort;
1018 let effort_summary = if applied_effort == ReasoningEffort::Auto {
1019 "auto (per-turn thinking)".to_string()
1020 } else {
1021 applied_effort
1022 .display_label_for_provider(app.api_provider)
1023 .to_string()
1024 };
1025
1026 let summary = match (model_changed, effort_changed) {
1027 (true, true) => format!(
1028 "Model: {previous_model} → {model_summary} · thinking: {previous_effort_summary} → {effort_summary}"
1029 ),
1030 (true, false) => {
1031 format!("Model: {previous_model} → {model_summary} · thinking {effort_summary}")
1032 }
1033 (false, true) => format!(
1034 "Thinking: {previous_effort_summary} → {effort_summary} · model {model_summary}"
1035 ),
1036 (false, false) => {
1037 format!("Model unchanged: {model_summary} · thinking {effort_summary}")
1038 }
1039 };
1040 app.status_message = Some(summary);
1041 // Setup progress records that a concrete route was selected successfully;
1042 // it is a local receipt, not a claim that the route became the default.
1043 if model_changed || !model_is_auto {
1044 record_provider_model_setup_progress(app, config);
1045 }
1046 if save_as_startup_default {
1047 app.status_message = Some(app.save_live_route_as_startup_default());
1048 }
1049 }
1050
1051 pub(crate) async fn apply_picker_effort_choice(
1052 app: &mut App,
1053 engine_handle: &EngineHandle,
1054 effort: ReasoningEffort,
1055 previous_effort: ReasoningEffort,
1056 ) {
1057 if app
1058 .reject_setting_change_while_busy(codewhale_localization::MessageId::SettingSubjectThinking)
1059 {
1060 return;
1061 }
1062 let effective_effort = if app.auto_model {
1063 effort
1064 } else {
1065 effort.normalize_for_route(app.api_provider, &app.active_route_base_url, &app.model)
1066 };
1067 let live_changed = effective_effort != app.reasoning_effort;
1068 let preference_changed = app.reasoning_effort_preference != Some(effort);
1069 let selection_changed = effort != previous_effort || live_changed;
1070
1071 if live_changed || preference_changed {
1072 app.reasoning_effort = effective_effort;
1073 app.reasoning_effort_preference = Some(effort);
1074 }
1075 if selection_changed {
1076 app.invalidate_route_receipts_for_reasoning_change();
1077 app.update_model_compaction_budget();
1078 }
1079
1080 let persist_warning = app
1081 .startup_defaults
1082 .apply_blocking(
1083 crate::tui::startup_defaults::StartupDefaults::reasoning_effort(effort.as_setting()),
1084 )
1085 .err()
1086 .map(|err| format!(" (not persisted: {err})"));
1087
1088 if live_changed {
1089 apply_model_and_compaction_update(
1090 engine_handle,
1091 app.compaction_config(),
1092 app.mode,
1093 app.active_route_limits,
1094 )
1095 .await;
1096 }
1097
1098 let persisted = persist_warning.is_none();
1099 let mut summary = if selection_changed {
1100 format!(
1101 "Thinking: {} → {} · model {}",
1102 previous_effort.display_label_for_provider(app.api_provider),
1103 effort.display_label_for_provider(app.api_provider),
1104 app.model_display_label()
1105 )
1106 } else {
1107 let mut summary = format!(
1108 "Thinking unchanged: {} · model {}",
1109 effort.display_label_for_provider(app.api_provider),
1110 app.model_display_label()
1111 );
1112 if persisted {
1113 summary.push_str(" · ");
1114 summary.push_str(&app.tr(codewhale_localization::MessageId::SavedAsStartupDefault));
1115 }
1116 summary
1117 };
1118 if let Some(warning) = persist_warning {
1119 summary.push_str(&warning);
1120 }
1121 app.status_message = Some(summary);
1122 }
1123
1124 pub(crate) async fn apply_provider_fallback_switch(
1125 app: &mut App,
1126 engine_handle: &mut EngineHandle,
1127 config: &mut Config,
1128 rollback: ProviderFallbackRollback,
1129 ) {
1130 let ProviderFallbackRollback {
1131 identity: previous_identity,
1132 chain: previous_chain,
1133 } = rollback;
1134 let previous_provider = previous_identity.provider;
1135 let target = app.api_provider;
1136 let previous_model = app.model.clone();
1137
1138 let resolved_route = match resolve_runtime_route(config, target, None) {
1139 Ok(route) => route,
1140 Err(reason) => {
1141 app.set_provider_identity_record(previous_identity.clone());
1142 app.provider_chain = previous_chain.clone();
1143 app.last_fallback_reason = Some(format!(
1144 "Fallback provider {} route was rejected: {reason}",
1145 target.as_str()
1146 ));
1147 app.status_message = Some(format!(
1148 "Fallback provider {} rejected; provider remains {}.",
1149 target.as_str(),
1150 previous_provider.as_str()
1151 ));
1152 return;
1153 }
1154 };
1155 let target_identity = resolved_route.identity.clone();
1156 let resolved_endpoint = resolved_route.candidate.endpoint().base_url.clone();
1157 let next_config = resolved_route.config;
1158 let new_model = resolved_route.model;
1159 let context_window_source = resolved_route.context_window.source;
1160
1161 if let Err(err) = CodewhaleClient::from_candidate(&next_config, &resolved_route.candidate) {
1162 app.set_provider_identity_record(previous_identity);
1163 app.provider_chain = previous_chain;
1164 app.last_fallback_reason = Some(format!(
1165 "Fallback provider {} was unavailable: {err}",
1166 target.as_str()
1167 ));
1168 app.status_message = Some(format!(
1169 "Fallback provider {} unavailable; provider remains {}.",
1170 target.as_str(),
1171 previous_provider.as_str()
1172 ));
1173 return;
1174 }
1175 *config = *next_config;
1176 app.refresh_notification_settings(config);
1177 app.set_provider_identity_record(target_identity);
1178 app.billing_presentation = crate::route_billing::for_route(config, target);
1179
1180 let new_base_url = resolved_endpoint;
1181 let new_endpoint = display_base_url_host(&new_base_url);
1182 let cache_scope_changed = previous_provider != target || previous_model != new_model;
1183 app.model_ids_passthrough = config.model_ids_pass_through();
1184 app.set_model_selection(new_model.clone());
1185 app.apply_provider_switch_reasoning_effort(target, &new_base_url, None);
1186 app.set_active_context_window_override(config, target);
1187 app.set_active_route_resolution(
1188 new_base_url.clone(),
1189 resolved_route.candidate.limits(),
1190 context_window_source,
1191 );
1192 app.update_model_compaction_budget();
1193 if cache_scope_changed {
1194 app.clear_model_scoped_telemetry();
1195 } else {
1196 app.session.last_prompt_tokens = None;
1197 app.session.last_completion_tokens = None;
1198 }
1199
1200 let _ = engine_handle.send(Op::Shutdown).await;
1201 let engine_config = build_engine_config(app, config);
1202 *engine_handle = spawn_tui_engine(engine_config, config);
1203
1204 if !app.api_messages.is_empty() {
1205 let _ = engine_handle
1206 .send(Op::SyncSession {
1207 session_id: app.current_session_id.clone(),
1208 messages: app.api_messages.as_ref().clone(),
1209 system_prompt: app.system_prompt.clone(),
1210 system_prompt_override: false,
1211 model: app.model.clone(),
1212 workspace: app.workspace.clone(),
1213 mode: app.mode,
1214 })
1215 .await;
1216 }
1217 let _ = engine_handle
1218 .send(Op::SetCompaction {
1219 config: app.compaction_config(),
1220 })
1221 .await;
1222
1223 app.add_message(HistoryCell::System {
1224 content: format!(
1225 "Provider fallback: {} -> {}\nModel: {} -> {}\nEndpoint: {}",
1226 previous_provider.as_str(),
1227 target.as_str(),
1228 previous_model,
1229 new_model,
1230 new_endpoint
1231 ),
1232 });
1233 app.status_message = Some(format!(
1234 "Fallback provider: {} via {}",
1235 target.as_str(),
1236 new_endpoint
1237 ));
1238 }
1239
1240 pub(super) fn reject_inline_inference_while_runtime_chat_owns_run(
1241 app: &mut App,
1242 result: &commands::CommandResult,
1243 ) -> bool {
1244 let blocked_inline_inference = matches!(
1245 result.action.as_ref(),
1246 Some(AppAction::VoiceCapture | AppAction::CacheWarmup)
1247 ) && app.remote_control.runtime_chat_blocks_local_dispatch();
1248 if !blocked_inline_inference {
1249 return false;
1250 }
1251 if matches!(result.action.as_ref(), Some(AppAction::VoiceCapture)) {
1252 // `/voice` toggles this before returning the action. Restore the state
1253 // so a retry after relay settlement starts capture rather than merely
1254 // toggling the stale flag off.
1255 app.voice_enabled = false;
1256 }
1257 let notice = app
1258 .tr(MessageId::SettingLockedDuringTurn)
1259 .replace("{setting}", "Codewhale Runtime");
1260 app.push_status_toast(notice, crate::tui::app::StatusToastLevel::Info, Some(6_000));
1261 true
1262 }
1263
1264 pub(crate) fn apply_notification_update(
1265 app: &mut App,
1266 config: &mut Config,
1267 update: crate::config::NotificationConfigUpdate,
1268 ) -> Result<()> {
1269 let mut notifications = config.notifications_config();
1270 let setting = update.setting();
1271 notifications.apply_update(update).map_err(|_| {
1272 anyhow::anyhow!(
1273 app.tr(MessageId::ConfigCommandInvalidValue)
1274 .replace("{key}", &format!("notifications.{}", setting.key()))
1275 .replace("{value}", &app.tr(MessageId::ConfigUnavailable))
1276 .replace("{choices}", setting.choices())
1277 )
1278 })?;
1279 config.notifications = Some(notifications);
1280 app.refresh_notification_settings(config);
1281 Ok(())
1282 }
1283
1284 pub(crate) async fn apply_command_result(
1285 terminal: &mut AppTerminal,
1286 app: &mut App,
1287 engine_handle: &mut EngineHandle,
1288 task_manager: &SharedTaskManager,
1289 config: &mut Config,
1290 result: commands::CommandResult,
1291 ) -> Result<bool> {
1292 // These two actions await participant inference inline on the UI event
1293 // loop. Waiting behind Runtime Chat's exclusive writer here would
1294 // deadlock: this same loop must drain the terminal projection/server
1295 // cursor that releases the writer. Fail closed before displaying the
1296 // command's optimistic message or invoking recorder/provider code.
1297 if reject_inline_inference_while_runtime_chat_owns_run(app, &result) {
1298 return Ok(false);
1299 }
1300 if let Some(msg) = result.message
1301 && !matches!(result.action, Some(AppAction::OpenCommandReview { .. }))
1302 {
1303 app.add_message(HistoryCell::System { content: msg });
1304 }
1305
1306 if let Some(action) = result.action {
1307 match action {
1308 AppAction::Quit => {
1309 let _ = engine_handle.send(Op::Shutdown).await;
1310 return Ok(true);
1311 }
1312 AppAction::LoadSession(path) => {
1313 // Session files can be large; this is the UI action path, so
1314 // the read must not park a Tokio worker (blocking-call
1315 // convention, #6149).
1316 let parsed: SavedSession = match tokio::fs::read_to_string(&path)
1317 .await
1318 .map_err(|err| err.to_string())
1319 .and_then(|raw| serde_json::from_str(&raw).map_err(|err| err.to_string()))
1320 {
1321 Ok(session) => session,
1322 Err(err) => {
1323 crate::tui::ui::session_state::surface_session_load_failure(
1324 app,
1325 format!("Failed to load session from {}: {err}", path.display()),
1326 );
1327 return Ok(false);
1328 }
1329 };
1330 // A managed record resumes through the manager so its repair is
1331 // hydrated, applied, and persisted in place. A foreign `/load`
1332 // file is not ours to rewrite: hydrate its journal projection
1333 // and repair in memory only.
1334 let session = match SessionManager::default_location() {
1335 Ok(manager) if manager.owns_session_path(&parsed.metadata.id, &path) => {
1336 match manager.resume_session(&parsed.metadata.id) {
1337 Ok(recovery) => recovery.session,
1338 Err(err) => {
1339 crate::tui::ui::session_state::surface_session_load_failure(
1340 app,
1341 format!("Failed to resume session {}: {err}", path.display()),
1342 );
1343 return Ok(false);
1344 }
1345 }
1346 }
1347 _ => {
1348 let mut session = parsed;
1349 session.ensure_journal();
1350 crate::session_manager::repair_recovered_session(&mut session);
1351 session
1352 }
1353 };
1354 let fresh_config =
1355 match Config::load(app.config_path.clone(), app.config_profile.as_deref()) {
1356 Ok(config) => config,
1357 Err(err) => {
1358 crate::tui::ui::session_state::surface_session_load_failure(
1359 app,
1360 format!("Failed to load live config for session restore: {err}"),
1361 );
1362 return Ok(false);
1363 }
1364 };
1365 let respawn = match apply_loaded_session_config_snapshot(
1366 app,
1367 config,
1368 &session,
1369 fresh_config,
1370 true,
1371 ) {
1372 Ok(outcome) => outcome,
1373 Err(err) => {
1374 crate::tui::ui::session_state::surface_session_load_failure(
1375 app,
1376 format!("Failed to restore session: {err}"),
1377 );
1378 return Ok(false);
1379 }
1380 };
1381 sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
1382 if respawn {
1383 let _ = engine_handle.send(Op::Shutdown).await;
1384 *engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
1385 } else {
1386 let _ = engine_handle
1387 .send(Op::SetModel {
1388 model: app.model.clone(),
1389 mode: app.mode,
1390 route_limits: app.active_route_limits,
1391 })
1392 .await;
1393 }
1394 let _ = engine_handle
1395 .send(Op::SyncSession {
1396 session_id: app.current_session_id.clone(),
1397 messages: app.api_messages.as_ref().clone(),
1398 system_prompt: app.system_prompt.clone(),
1399 system_prompt_override: false,
1400 model: app.model.clone(),
1401 workspace: app.workspace.clone(),
1402 mode: app.mode,
1403 })
1404 .await;
1405 let _ = engine_handle
1406 .send(Op::SetCompaction {
1407 config: app.compaction_config(),
1408 })
1409 .await;
1410 let title = crate::session_manager::sanitize_session_title(&session.metadata.title);
1411 // Restore may have queued a legacy configuration notice.
1412 // Admit it first so the confirmed resume remains the latest
1413 // toast instead of being immediately covered on the next draw.
1414 app.sync_status_message_to_toasts();
1415 app.push_status_toast_record(
1416 StatusToast::new(
1417 app.tr(MessageId::SessionsResumed)
1418 .replace("{title}", &title),
1419 StatusToastLevel::Success,
1420 Some(4_000),
1421 )
1422 .for_event(format!("session-resumed:{}", session.metadata.id)),
1423 );
1424 // A loaded session is the working screen. The launch card's
1425 // recent rows reach here through `/resume`-shaped dispatch;
1426 // leaving the launch stage visible over the restored
1427 // transcript is what made those rows read as dead (#4).
1428 app.launch.dismiss();
1429 app.launch.status = None;
1430 }
1431 AppAction::SyncSession {
1432 session_id,
1433 messages,
1434 system_prompt,
1435 model,
1436 workspace,
1437 mode,
1438 } => {
1439 let mut session_id = session_id;
1440 let is_full_reset = messages.is_empty() && system_prompt.is_none();
1441 if is_full_reset && session_id.is_none() {
1442 let new_session_id = uuid::Uuid::new_v4().to_string();
1443 session_id = Some(new_session_id);
1444 }
1445 if let Some(session_id) = session_id.as_deref() {
1446 let transition = match prepare_offline_queue_transition(app, session_id) {
1447 Ok(transition) => transition,
1448 Err(error) => {
1449 app.push_status_toast(error, StatusToastLevel::Error, Some(6_000));
1450 return Ok(false);
1451 }
1452 };
1453 install_offline_queue_transition(app, transition);
1454 }
1455 let workspace_changed = task_manager.default_workspace().await != workspace;
1456 if workspace_changed {
1457 apply_workspace_runtime_state(app, config, workspace.clone());
1458 sync_runtime_workspace_state(task_manager, workspace.clone()).await;
1459 }
1460 let provider_changed = config.api_provider() != app.api_provider
1461 || config.provider_identity_for(config.api_provider())
1462 != app.provider_identity_for_persistence();
1463 if provider_changed {
1464 let identity = match config
1465 .resolve_provider_identity(app.provider_identity_for_persistence())
1466 {
1467 Ok(identity) => identity,
1468 Err(err) => {
1469 app.status_message =
1470 Some(format!("Failed to restore saved session provider: {err}"));
1471 return Ok(false);
1472 }
1473 };
1474 restore_loaded_session_provider(app, config, identity);
1475 config.set_provider_model_override(app.api_provider, Some(model.clone()));
1476 }
1477 // Re-resolve from the live config even when the provider did
1478 // not change. The command layer intentionally has no Config
1479 // handle, so its provisional limits cannot include current
1480 // provider overrides.
1481 resolve_loaded_session_route(app, config);
1482 app.update_model_compaction_budget();
1483 if provider_changed || workspace_changed {
1484 let _ = engine_handle.send(Op::Shutdown).await;
1485 *engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
1486 }
1487 // SyncSession carries the conversation but not resolved route
1488 // limits. Refresh the engine's model first so a loaded,
1489 // forked, or freshly reset session cannot retain the previous
1490 // route's context/output facts.
1491 let _ = engine_handle
1492 .send(Op::SetModel {
1493 model: model.clone(),
1494 mode,
1495 route_limits: app.active_route_limits,
1496 })
1497 .await;
1498 let _ = engine_handle
1499 .send(Op::SyncSession {
1500 session_id,
1501 messages,
1502 system_prompt,
1503 system_prompt_override: false,
1504 model,
1505 workspace,
1506 mode,
1507 })
1508 .await;
1509 let _ = engine_handle
1510 .send(Op::SetCompaction {
1511 config: app.compaction_config(),
1512 })
1513 .await;
1514 if is_full_reset {
1515 persist_full_reset_snapshot(app);
1516 }
1517 }
1518 AppAction::ModeChanged(_mode) => {
1519 sync_mode_update(app, engine_handle).await;
1520 }
1521 AppAction::ApprovalPolicyPersisted { policy } => {
1522 config.approval_policy = policy;
1523 sync_mode_update(app, engine_handle).await;
1524 }
1525 AppAction::PermissionRulesChanged => {
1526 match codewhale_config::load_permissions_snapshot(app.config_path.clone()) {
1527 Ok(snapshot) => {
1528 let ruleset = snapshot.permissions().ruleset();
1529 // Config and every running EngineConfig share this
1530 // policy store. Publish once: replaying an older Op
1531 // after a later edit would roll the live policy back.
1532 config.exec_policy_engine.set_ruleset(ruleset);
1533 }
1534 Err(error) => {
1535 app.status_message = Some(
1536 tr(app.ui_locale, MessageId::PermissionsOperationFailed)
1537 .replace("{error}", &format!("{error:#}")),
1538 );
1539 }
1540 }
1541 }
1542 AppAction::PluginRegistryChanged => {
1543 let command_errors = crate::commands::user_registry::install_plugin_registry(
1544 &app.workspace,
1545 app.plugin_registry.as_ref(),
1546 );
1547 app.hooks = app.hooks.rebind(
1548 crate::hooks::HooksConfig::load_with_project_and_plugins(
1549 config.hooks_config(),
1550 &app.workspace,
1551 Some(app.plugin_registry.as_ref()),
1552 ),
1553 app.workspace.clone(),
1554 );
1555 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
1556 if !command_errors.is_empty() {
1557 app.set_sticky_status(
1558 format!(
1559 "Plugin runtime activation failed: {}",
1560 command_errors.join("; ")
1561 ),
1562 StatusToastLevel::Error,
1563 None,
1564 );
1565 }
1566 let _ = engine_handle.send(Op::Shutdown).await;
1567 *engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
1568 if !app.api_messages.is_empty() {
1569 let _ = engine_handle
1570 .send(Op::SyncSession {
1571 session_id: app.current_session_id.clone(),
1572 messages: app.api_messages.as_ref().clone(),
1573 system_prompt: app.system_prompt.clone(),
1574 system_prompt_override: false,
1575 model: app.model.clone(),
1576 workspace: app.workspace.clone(),
1577 mode: app.mode,
1578 })
1579 .await;
1580 }
1581 }
1582 AppAction::SendMessage(content) => {
1583 let queued = build_queued_message(app, content);
1584 dispatch_composer_message(
1585 app,
1586 config,
1587 engine_handle,
1588 queued,
1589 DispatchRecovery::Immediate,
1590 ComposerSubmitAction::Submit(app.decide_submit_disposition()),
1591 )
1592 .await?;
1593 }
1594 AppAction::WorkflowInstruction {
1595 display,
1596 instruction,
1597 } => {
1598 let queued = QueuedMessage::new(display, Some(instruction));
1599 dispatch_composer_message(
1600 app,
1601 config,
1602 engine_handle,
1603 queued,
1604 DispatchRecovery::Immediate,
1605 ComposerSubmitAction::Submit(app.decide_submit_disposition()),
1606 )
1607 .await?;
1608 }
1609 AppAction::SetGoalStatus { status, clear } => {
1610 accept_goal_control(
1611 app,
1612 engine_handle,
1613 GoalControlIntent::SetStatus { status, clear },
1614 );
1615 }
1616 AppAction::SetGoalObjective {
1617 objective,
1618 token_budget,
1619 } => {
1620 accept_goal_control(
1621 app,
1622 engine_handle,
1623 GoalControlIntent::SetObjective {
1624 objective,
1625 token_budget,
1626 },
1627 );
1628 }
1629 AppAction::OpenTextPager { title, content } => {
1630 open_text_pager(app, title, content);
1631 }
1632 AppAction::OpenCommandReview {
1633 title,
1634 content,
1635 command,
1636 } => {
1637 let width = app
1638 .viewport
1639 .last_transcript_area
1640 .map_or(80, |area| area.width);
1641 app.view_stack
1642 .push(crate::tui::pager::PagerView::command_review(
1643 title,
1644 &content,
1645 width.saturating_sub(2),
1646 command,
1647 app.ui_locale,
1648 ));
1649 app.needs_redraw = true;
1650 }
1651 AppAction::VoiceCapture => {
1652 use commands::voice::VoiceCaptureOutcome;
1653 match commands::voice::capture_and_transcribe(app, config).await {
1654 Ok(VoiceCaptureOutcome::Insert(text)) => {
1655 app.insert_str(&text);
1656 app.status_message = Some(format!(
1657 "{}: {text}",
1658 tr(app.ui_locale, MessageId::VoiceTranscribed)
1659 ));
1660 }
1661 Ok(VoiceCaptureOutcome::Send(content)) => {
1662 app.status_message =
1663 Some(tr(app.ui_locale, MessageId::VoiceTranscribed).to_string());
1664 let queued = build_queued_message(app, content);
1665 dispatch_composer_message(
1666 app,
1667 config,
1668 engine_handle,
1669 queued,
1670 DispatchRecovery::Immediate,
1671 ComposerSubmitAction::Submit(app.decide_submit_disposition()),
1672 )
1673 .await?;
1674 }
1675 Err(err) => {
1676 app.voice_enabled = false;
1677 app.status_message = Some(err);
1678 }
1679 }
1680 }
1681 AppAction::ListSubAgents => {
1682 // #3802: non-blocking send — refresh op, safe to drop.
1683 let _ = engine_handle.try_send(Op::ListSubAgents);
1684 }
1685 AppAction::PreviewOutboundRequest {
1686 json,
1687 base_prompt_only,
1688 hypothetical_prompt,
1689 } => {
1690 // Split of authority: the host resolves the next turn's route
1691 // with the same planner it would use to send one, and the
1692 // engine — the only place that can rebuild the tool catalog,
1693 // MCP state, gates, system prompt, and prepared body — turns
1694 // that plan into a manifest.
1695 let inputs =
1696 build_preview_request_inputs(app, config, engine_handle, hypothetical_prompt)
1697 .await;
1698 // #6150: the input path never awaits a full op channel; a
1699 // rejected preview is reported and retryable.
1700 if let Err(err) = engine_handle.try_send(Op::PreviewOutboundRequest {
1701 inputs: Box::new(inputs),
1702 json,
1703 base_prompt_only,
1704 }) {
1705 app.status_message = Some(format!("Cannot preview request: {err}"));
1706 }
1707 }
1708 AppAction::CancelSubAgent { agent_id } => {
1709 app.status_message = Some(format!("Cancelling {agent_id}..."));
1710 if engine_handle
1711 .try_send(Op::CancelSubAgent {
1712 agent_id: agent_id.clone(),
1713 })
1714 .is_err()
1715 {
1716 app.status_message = Some(format!("Could not cancel {agent_id}"));
1717 }
1718 }
1719 AppAction::FetchBalance => {
1720 let provider = app.api_provider;
1721 if !crate::config::provider_has_balance_api(provider) {
1722 app.add_message(HistoryCell::System {
1723 content: format!(
1724 "Balance check is not supported for {} yet. Check the provider dashboard for account balance details.",
1725 provider.display_name()
1726 ),
1727 });
1728 } else {
1729 let api_key = config.active_route_api_key().unwrap_or_default();
1730 if api_key.trim().is_empty() {
1731 app.add_message(HistoryCell::System {
1732 content: format!(
1733 "No API key configured for {}.",
1734 provider.display_name()
1735 ),
1736 });
1737 } else {
1738 let base_url = config.active_route_base_url();
1739 match fetch_provider_balance(provider, &api_key, &base_url).await {
1740 Some(info) => {
1741 if let Ok(mut guard) = app.balance_cell.lock() {
1742 *guard = Some(info.clone());
1743 }
1744 app.last_balance_fetch = Some(Instant::now());
1745 app.add_message(HistoryCell::System {
1746 content: info.report(provider.display_name()),
1747 });
1748 }
1749 None => {
1750 let fallback = app
1751 .balance_cell
1752 .lock()
1753 .ok()
1754 .and_then(|guard| guard.clone())
1755 .and_then(|info| {
1756 info.chip_label().map(|amount| {
1757 format!(
1758 "Could not refresh {} balance; last known: {amount}",
1759 provider.display_name()
1760 )
1761 })
1762 });
1763 app.add_message(HistoryCell::System {
1764 content: fallback.unwrap_or_else(|| {
1765 format!(
1766 "Could not fetch {} account balance. Check the provider dashboard.",
1767 provider.display_name()
1768 )
1769 }),
1770 });
1771 }
1772 }
1773 }
1774 }
1775 }
1776 AppAction::FetchModels => {
1777 app.status_message = Some("Fetching models...".to_string());
1778 match fetch_available_models(config).await {
1779 Ok(models) => {
1780 app.add_message(HistoryCell::System {
1781 content: format_helpers::available_models_message(
1782 app.ui_locale,
1783 app.provider_identity_for_persistence(),
1784 &app.model,
1785 &models,
1786 &crate::fleet::members::fleet_models(&app.workspace),
1787 ),
1788 });
1789 app.status_message = Some(format!("Found {} model(s)", models.len()));
1790 }
1791 Err(error) => {
1792 app.add_message(HistoryCell::System {
1793 content: format!(
1794 "Failed to fetch models from {}: {error}",
1795 config.api_provider().display_name()
1796 ),
1797 });
1798 }
1799 }
1800 }
1801 AppAction::RefreshModelsDevCatalog => {
1802 app.status_message = Some("Refreshing Models.dev catalog...".to_string());
1803 let message = match crate::models_dev_live::refresh(true).await {
1804 Ok(count) => {
1805 let status = crate::models_dev_live::status();
1806 let source = if status.source_label.is_empty() {
1807 "unknown"
1808 } else {
1809 status.source_label.as_str()
1810 };
1811 format!(
1812 "Models.dev catalog refreshed: {count} offerings ({:?}, source {source})",
1813 status.freshness
1814 )
1815 }
1816 Err(err) => {
1817 let status = crate::models_dev_live::status();
1818 format!(
1819 "Models.dev refresh failed ({err}); keeping prior/bundled rows ({} offerings, {:?})",
1820 status.offering_count, status.freshness
1821 )
1822 }
1823 };
1824 app.add_message(HistoryCell::System {
1825 content: message.clone(),
1826 });
1827 app.status_message = Some(message);
1828 // `/model refresh` also forces the cloud facts overlay when the
1829 // (off-by-default) channel is enabled.
1830 let cloud_settings = config.cloud_facts_config().settings();
1831 codewhale_cloud_facts::configure(&cloud_settings);
1832 if cloud_settings.enabled {
1833 let now = codewhale_config::catalog::now_unix();
1834 let cloud = match codewhale_cloud_facts::refresh(&cloud_settings, true).await {
1835 Ok(outcome) => {
1836 format!(
1837 "Cloud facts refreshed: {outcome:?} ({})",
1838 codewhale_cloud_facts::status().label(now)
1839 )
1840 }
1841 Err(err) => format!(
1842 "Cloud facts refresh failed ({err}); {}",
1843 codewhale_cloud_facts::status().label(now)
1844 ),
1845 };
1846 app.add_message(HistoryCell::System { content: cloud });
1847 }
1848 }
1849 AppAction::CacheWarmup => {
1850 app.status_message = Some("Warming prompt cache...".to_string());
1851 match run_cache_warmup(app, config).await {
1852 Ok(outcome) => {
1853 app.session.last_base_url = Some(outcome.base_url.clone());
1854 app.session.last_warmup_key = Some(CacheWarmupKey::from_inspection(
1855 &outcome.provider_identity,
1856 &outcome.model,
1857 &outcome.base_url,
1858 &outcome.inspection,
1859 ));
1860 let mut message = format_helpers::cache_warmup_result(&outcome.usage);
1861 if let Some(key) = app.session.last_warmup_key.as_ref() {
1862 message.push_str(&format!("\nWarmup key: {}", key.hash_short()));
1863 }
1864 // Append prefix-cache stability info.
1865 if app.prefix_checks_total > 0 {
1866 let changes = app.prefix_change_count;
1867 let total = app.prefix_checks_total;
1868 let stable = total.saturating_sub(changes);
1869 let pct = app
1870 .prefix_stability_pct
1871 .map(|p| format!("{p}%"))
1872 .unwrap_or_else(|| "--".to_string());
1873 message.push_str(&format!(
1874 "\n\nPrefix stability: {pct} ({stable}/{total} checks stable, {changes} change{})",
1875 if changes == 1 { "" } else { "s" }
1876 ));
1877 if let Some(ref desc) = app.last_prefix_change_desc {
1878 message.push_str(&format!("\nLast prefix change: {desc}"));
1879 }
1880 }
1881 app.add_message(HistoryCell::System { content: message });
1882 app.status_message = Some("Cache warmup complete".to_string());
1883 }
1884 Err(error) => {
1885 app.add_message(HistoryCell::System {
1886 content: format!("Cache warmup failed: {error}"),
1887 });
1888 app.status_message = Some("Cache warmup failed".to_string());
1889 }
1890 }
1891 }
1892 AppAction::SwitchProvider { provider, model } => {
1893 switch_provider(app, engine_handle, config, provider, model).await;
1894 let api_key = config.active_route_api_key().unwrap_or_default();
1895 let base_url = config.active_route_base_url();
1896 schedule_balance_fetch(app, &api_key, &base_url, false);
1897 }
1898 AppAction::SwitchModelRoute { provider, model } => {
1899 let previous_model = if app.auto_model {
1900 "auto".to_string()
1901 } else {
1902 app.model.clone()
1903 };
1904 // Hotbar route actions do not carry an effort choice. Preserve
1905 // the raw global preference instead of feeding a fixed
1906 // route's normalized live tier back through the picker path.
1907 let previous_effort = app
1908 .reasoning_effort_preference
1909 .unwrap_or(app.reasoning_effort);
1910 apply_model_picker_choice(
1911 app,
1912 engine_handle,
1913 config,
1914 model,
1915 Some(provider),
1916 None,
1917 previous_effort,
1918 previous_model,
1919 previous_effort,
1920 // A hotbar route switch is a session action, not a
1921 // statement about what the next launch should open with.
1922 false,
1923 )
1924 .await;
1925 }
1926 AppAction::UpdateCompaction(compaction) => {
1927 if app.is_loading || app.is_compacting {
1928 let queued = try_apply_model_and_compaction_update(
1929 engine_handle,
1930 compaction,
1931 app.mode,
1932 app.active_route_limits,
1933 );
1934 app.status_message = Some(if queued {
1935 "Config change queued; the active turn remains responsive.".to_string()
1936 } else {
1937 "Config change deferred; it will apply to the next turn.".to_string()
1938 });
1939 } else {
1940 apply_model_and_compaction_update(
1941 engine_handle,
1942 compaction,
1943 app.mode,
1944 app.active_route_limits,
1945 )
1946 .await;
1947 }
1948 }
1949 AppAction::UpdateStreamChunkTimeout(timeout_secs) => {
1950 // #6150: the input path never awaits a full op channel.
1951 if engine_handle
1952 .try_send(Op::SetStreamChunkTimeout { timeout_secs })
1953 .is_err()
1954 {
1955 app.status_message =
1956 Some("Engine busy — setting not applied; try again".to_string());
1957 }
1958 }
1959 AppAction::UpdateSubagentRuntimeConfig {
1960 enabled,
1961 max_subagents,
1962 launch_concurrency,
1963 max_spawn_depth,
1964 api_timeout_secs,
1965 heartbeat_timeout_secs,
1966 } => {
1967 if engine_handle
1968 .try_send(Op::SetSubagentRuntimeConfig {
1969 enabled,
1970 max_subagents,
1971 launch_concurrency,
1972 max_spawn_depth,
1973 api_timeout_secs,
1974 heartbeat_timeout_secs,
1975 })
1976 .is_err()
1977 {
1978 app.status_message =
1979 Some("Engine busy — setting not applied; try again".to_string());
1980 }
1981 }
1982 AppAction::UpdateSearchProvider { provider } => {
1983 // Reserve before committing the config change so a full
1984 // channel cannot desync the engine from it.
1985 match engine_handle.tx_op.clone().try_reserve_owned() {
1986 Ok(permit) => {
1987 let effective_provider = config.set_search_provider(provider);
1988 engine_handle.send_reserved_op(
1989 permit,
1990 Op::SetSearchProvider {
1991 provider: effective_provider,
1992 },
1993 );
1994 }
1995 Err(_) => {
1996 app.status_message =
1997 Some("Engine busy — provider not applied; try again".to_string());
1998 }
1999 }
2000 }
2001 AppAction::UpdatePromptSuggestion { enabled } => {
2002 config.prompt_suggestion = Some(enabled);
2003 }
2004 AppAction::UpdateNotification { update } => {
2005 if let Err(error) = apply_notification_update(app, config, update) {
2006 app.push_status_toast(error.to_string(), StatusToastLevel::Error, Some(6_000));
2007 }
2008 }
2009 AppAction::SetAdvisorEnabled { enabled } => {
2010 if engine_handle
2011 .try_send(Op::SetAdvisorEnabled { enabled })
2012 .is_err()
2013 {
2014 app.status_message =
2015 Some("Engine busy — setting not applied; try again".to_string());
2016 }
2017 }
2018 AppAction::OpenConfigView => {
2019 if app.view_stack.top_kind() != Some(ModalKind::Config) {
2020 app.view_stack.push(ConfigView::new_for_app(app));
2021 }
2022 }
2023 AppAction::OpenWorktreeManager => {
2024 if app.view_stack.top_kind() != Some(ModalKind::WorktreeManager) {
2025 // Non-blocking: git_status caches; manager never shells on paint.
2026 crate::tui::git_status::refresh_if_stale(&app.workspace);
2027 app.view_stack
2028 .push(crate::tui::worktree_manager::WorktreeManagerView::new(
2029 app.workspace.clone(),
2030 ));
2031 }
2032 }
2033 AppAction::OpenModelPicker => {
2034 if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
2035 // Slash `/model` and the picker share one grammar: the
2036 // composer must not keep a leftover `/model` buffer
2037 // (or a held paste-burst) under the picker query.
2038 app.clear_input();
2039 app.paste_burst.clear_after_explicit_paste();
2040 app.view_stack
2041 .push(crate::tui::model_picker::ModelPickerView::new(app, config));
2042 }
2043 }
2044 AppAction::OpenProviderPicker => {
2045 open_provider_picker(app, config, engine_handle).await;
2046 }
2047 AppAction::OpenProviderSetup { provider } => {
2048 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
2049 let runtime_status = query_provider_runtime_status(engine_handle).await;
2050 app.view_stack.push(
2051 crate::tui::provider_picker::ProviderPickerView::new_for_setup(
2052 app.api_provider,
2053 provider,
2054 config,
2055 runtime_status,
2056 )
2057 .with_locale(app.ui_locale)
2058 .with_provider_health(&app.provider_health),
2059 );
2060 app.status_message = Some("Provider setup catalog opened.".to_string());
2061 }
2062 }
2063 AppAction::OpenDs4Setup => {
2064 if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
2065 let runtime_status = query_provider_runtime_status(engine_handle).await;
2066 app.view_stack.push(
2067 crate::tui::provider_picker::ProviderPickerView::new_for_ds4_setup(
2068 app.api_provider,
2069 config,
2070 runtime_status,
2071 )
2072 .with_locale(app.ui_locale)
2073 .with_provider_health(&app.provider_health),
2074 );
2075 }
2076 }
2077 AppAction::EditProjectHooks => {
2078 edit_project_hooks_from_tui(terminal, app, config);
2079 }
2080 AppAction::StartXaiDeviceLogin => {
2081 let _switched =
2082 run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
2083 }
2084 AppAction::StartChatgptPkceLogin => {
2085 let _switched =
2086 run_chatgpt_pkce_login_from_tui(terminal, app, engine_handle, config).await?;
2087 }
2088 AppAction::StartChatgptRevoke => {
2089 run_chatgpt_revoke_from_tui(app, config).await;
2090 }
2091 AppAction::SetScreenMode(mode) => {
2092 // The terminal transition is the only fallible part; a failed
2093 // probe leaves the previous screen live and says why.
2094 match switch_screen_mode(terminal, app, mode) {
2095 Ok(()) => {
2096 let screen = match mode {
2097 crate::tui::app::ScreenMode::Fullscreen => {
2098 app.tr(MessageId::ScreenModeFullscreenNotice)
2099 }
2100 crate::tui::app::ScreenMode::Inline => {
2101 app.tr(MessageId::ScreenModeInlineNotice)
2102 }
2103 };
2104 let capture = if app.use_mouse_capture {
2105 app.tr(MessageId::ScreenModeMouseCaptureOn)
2106 } else {
2107 app.tr(MessageId::ScreenModeMouseCaptureOff)
2108 };
2109 app.add_message(HistoryCell::System {
2110 content: format!("{screen} {capture}"),
2111 });
2112 }
2113 Err(reason) => {
2114 let unchanged = app
2115 .tr(MessageId::ScreenModeUnchanged)
2116 .replace("{reason}", &reason);
2117 app.add_message(HistoryCell::System {
2118 content: unchanged.clone(),
2119 });
2120 app.push_status_toast(
2121 unchanged.trim_end_matches('.').to_string(),
2122 StatusToastLevel::Warning,
2123 Some(8_000),
2124 );
2125 }
2126 }
2127 }
2128 AppAction::OpenModePicker => {
2129 if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
2130 app.view_stack
2131 .push(crate::tui::views::mode_picker::ModePickerView::new(
2132 app.mode,
2133 app.ui_locale,
2134 ));
2135 }
2136 }
2137 AppAction::OpenStatusPicker => {
2138 if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
2139 app.view_stack
2140 .push(crate::tui::views::status_picker::StatusPickerView::new(
2141 &app.status_items,
2142 app.api_provider,
2143 app.ui_locale,
2144 ));
2145 }
2146 }
2147 AppAction::OpenFeedbackPicker => {
2148 if app.view_stack.top_kind() != Some(ModalKind::FeedbackPicker) {
2149 app.view_stack
2150 .push(crate::tui::feedback_picker::FeedbackPickerView::new());
2151 }
2152 }
2153 AppAction::OpenThemePicker => {
2154 if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
2155 // Capture the active theme name straight from `app` so
2156 // Esc can revert through the same ConfigUpdated channel.
2157 // Avoids re-reading settings.toml from disk on every
2158 // `/theme` invocation.
2159 let original = app.theme_name.clone();
2160 app.view_stack
2161 .push_boxed(crate::tui::theme_picker::ThemePickerView::boxed(
2162 original,
2163 app.ui_locale,
2164 app.background_color_override,
2165 ));
2166 }
2167 }
2168 AppAction::OpenSkillsManager => {
2169 if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
2170 app.view_stack
2171 .push(crate::tui::views::skills_manager::SkillsManagerView::new(
2172 app,
2173 ));
2174 }
2175 }
2176 AppAction::OpenWorkflowsManager => {
2177 if app.view_stack.top_kind() != Some(ModalKind::WorkflowsManager) {
2178 app.view_stack
2179 .push(crate::tui::views::workflows_manager::WorkflowsManagerView::new(app));
2180 }
2181 }
2182 AppAction::OpenExtensions { tab } => {
2183 if app.view_stack.top_kind() != Some(ModalKind::Extensions) {
2184 app.view_stack
2185 .push(crate::tui::views::extensions::ExtensionsView::new(app, tab));
2186 }
2187 }
2188 AppAction::OpenFleetList => {
2189 if app.view_stack.top_kind() != Some(ModalKind::FleetList) {
2190 app.view_stack
2191 .push(crate::tui::views::fleet_list::FleetListView::new(
2192 app, config,
2193 ));
2194 }
2195 }
2196 AppAction::OpenFleetRoster => {
2197 if app.view_stack.top_kind() != Some(ModalKind::FleetRoster) {
2198 app.view_stack
2199 .push(crate::tui::views::fleet_roster::FleetRosterView::new(
2200 app, config,
2201 ));
2202 }
2203 }
2204 AppAction::OpenFleetSetup => {
2205 open_fleet_setup_target(app, config, None);
2206 }
2207 AppAction::FleetAddModel {
2208 provider,
2209 model,
2210 roles,
2211 } => {
2212 use crate::commands::{fleet_catalog_rejection, fleet_provider_rejection};
2213 let locale = app.ui_locale;
2214 // The live `config` is the provider truth: the startup
2215 // snapshot went stale after any in-session provider change.
2216 let rejection = fleet_provider_rejection(app, config, &provider)
2217 .or_else(|| fleet_catalog_rejection(locale, &provider, &model));
2218 let content = match rejection {
2219 Some(rejection) => rejection,
2220 None => match crate::fleet::members::add_fleet_model(
2221 &app.workspace,
2222 &provider,
2223 &model,
2224 &roles,
2225 ) {
2226 Ok(change) => {
2227 if !matches!(
2228 change,
2229 crate::fleet::members::FleetModelChange::Unchanged { .. }
2230 ) {
2231 app.fleet_roster_stale = true;
2232 }
2233 crate::fleet::members::change_receipt(
2234 locale, &provider, &model, &change,
2235 )
2236 }
2237 Err(error) => tr(locale, MessageId::FleetAddFailed)
2238 .replace("{error}", &error.message(locale)),
2239 },
2240 };
2241 app.add_message(HistoryCell::System { content });
2242 }
2243 AppAction::FleetRemoveModel { provider, model } => {
2244 let locale = app.ui_locale;
2245 let content = match crate::fleet::members::remove_fleet_model(
2246 &app.workspace,
2247 &provider,
2248 &model,
2249 ) {
2250 Ok(change) => {
2251 app.fleet_roster_stale = true;
2252 crate::fleet::members::change_receipt(locale, &provider, &model, &change)
2253 }
2254 Err(error) => tr(locale, MessageId::FleetRemoveFailed)
2255 .replace("{error}", &error.message(locale)),
2256 };
2257 app.add_message(HistoryCell::System { content });
2258 }
2259 AppAction::OpenHotbarSetup => {
2260 if app.view_stack.top_kind() != Some(ModalKind::HotbarSetup) {
2261 app.view_stack
2262 .push(crate::tui::hotbar::setup::HotbarSetupView::new(app, config));
2263 }
2264 }
2265 AppAction::OpenSetupWizard => {
2266 if app.view_stack.top_kind() != Some(ModalKind::SetupWizard) {
2267 let _ = app.next_draft_gen();
2268 app.view_stack
2269 .push(crate::tui::setup::SetupWizardView::new_for_app(app, config));
2270 }
2271 }
2272 AppAction::OpenSetupWizardAt { step } => {
2273 if app.view_stack.top_kind() != Some(ModalKind::SetupWizard) {
2274 let _ = app.next_draft_gen();
2275 app.view_stack
2276 .push(crate::tui::setup::SetupWizardView::new_for_app_at(
2277 app, config, step,
2278 ));
2279 }
2280 }
2281 AppAction::UseBundledConstitution => use_bundled_constitution(app, config),
2282 AppAction::PreviewEffectiveBasePrompt => preview_effective_base_prompt(app, config),
2283 AppAction::DisableHotbar => disable_hotbar(app, config),
2284 AppAction::RestoreHotbarDefaults => restore_hotbar_defaults(app, config),
2285 AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) {
2286 Ok(()) => {
2287 app.status_message = Some(format!("Opened {label} in your browser"));
2288 }
2289 Err(err) => {
2290 app.add_message(HistoryCell::System {
2291 content: format!(
2292 "Could not open {label} automatically: {err}\n\nThe URL is printed above."
2293 ),
2294 });
2295 }
2296 },
2297 AppAction::OpenContextInspector => {
2298 open_context_inspector(app);
2299 }
2300 AppAction::OpenLiveTranscript => {
2301 open_live_transcript_overlay(app);
2302 }
2303 AppAction::OpenTurnInspector => {
2304 open_turn_inspector_pager(app);
2305 }
2306 AppAction::CompactContext { focus } => {
2307 try_queue_manual_compaction(app, config, engine_handle, focus);
2308 }
2309 AppAction::PurgeContext => {
2310 if engine_handle.try_send(Op::PurgeContext).is_err() {
2311 app.status_message =
2312 Some("Engine busy — purge not sent; try again".to_string());
2313 } else {
2314 app.status_message = Some("Agent purging context...".to_string());
2315 }
2316 }
2317 AppAction::TaskAdd { prompt } => {
2318 let owner_session_id = app
2319 .current_session_id
2320 .clone()
2321 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2322 app.current_session_id = Some(owner_session_id.clone());
2323 let request = NewTaskRequest {
2324 prompt: prompt.clone(),
2325 name: None,
2326 model: Some(app.model.clone()),
2327 model_provider: Some(app.api_provider.as_str().to_string()),
2328 model_provider_id: Some(app.provider_identity_for_persistence().to_string()),
2329 workspace: Some(app.workspace.clone()),
2330 mode: Some(task_mode_label(app.mode).to_string()),
2331 allow_shell: Some(app.allow_shell),
2332 trust_mode: Some(app.trust_mode),
2333 auto_approve: Some(app_auto_approve_enabled(app)),
2334 owner_session_id: Some(owner_session_id),
2335 };
2336 match task_manager.add_task(request).await {
2337 Ok(task) => {
2338 app.add_message(HistoryCell::System {
2339 content: format!(
2340 "Task queued: {} ({})",
2341 task.id,
2342 summarize_tool_output(&task.prompt)
2343 ),
2344 });
2345 app.status_message = Some(format!("Queued {}", task.id));
2346 }
2347 Err(err) => {
2348 app.add_message(HistoryCell::System {
2349 content: format!("Failed to queue task: {err}"),
2350 });
2351 }
2352 }
2353 refresh_active_task_panel(app, task_manager).await;
2354 }
2355 AppAction::TaskList => {
2356 let tasks = match app.current_session_id.as_deref() {
2357 Some(session_id) => {
2358 task_manager
2359 .list_tasks_for_owner(Some(30), None, session_id)
2360 .await
2361 }
2362 None => Ok(Vec::new()),
2363 };
2364 refresh_active_task_panel(app, task_manager).await;
2365 app.add_message(HistoryCell::System {
2366 content: match tasks {
2367 Ok(tasks) => format_task_list(&tasks),
2368 Err(_) => codewhale_localization::tr(
2369 app.ui_locale,
2370 codewhale_localization::MessageId::TaskInventoryUnavailable,
2371 )
2372 .to_string(),
2373 },
2374 });
2375 }
2376 AppAction::RemoteControl(action) => match action {
2377 crate::remote_control::RemoteControlAction::Start => {
2378 start_remote_control_session(app, config);
2379 }
2380 crate::remote_control::RemoteControlAction::Stop => {
2381 app.remote_control.stop();
2382 let status = app.remote_control.status_line();
2383 app.sticky_status = None;
2384 app.status_message = Some(status);
2385 }
2386 },
2387 AppAction::TaskShow { id } => {
2388 let task = match app.current_session_id.as_deref() {
2389 Some(session_id) => {
2390 task_manager
2391 .get_task_for_interactive_session(&id, session_id)
2392 .await
2393 }
2394 None => Err(anyhow::anyhow!("Task not found: {id}")),
2395 };
2396 match task {
2397 Ok(task) => open_task_pager(app, &task),
2398 Err(err) => {
2399 app.add_message(HistoryCell::System {
2400 content: format!("Task lookup failed: {err}"),
2401 });
2402 }
2403 }
2404 }
2405 AppAction::TaskCancel { id } => {
2406 let cancellation = match app.current_session_id.as_deref() {
2407 Some(session_id) => {
2408 task_manager
2409 .cancel_task_for_interactive_session(&id, session_id)
2410 .await
2411 }
2412 None => Err(anyhow::anyhow!("Task not found: {id}")),
2413 };
2414 match cancellation {
2415 Ok(cancellation) => {
2416 app.add_message(HistoryCell::System {
2417 content: format!(
2418 "Task {} status: {:?}",
2419 cancellation.task.id, cancellation.task.status
2420 ),
2421 });
2422 }
2423 Err(err) => {
2424 app.add_message(HistoryCell::System {
2425 content: format!("Task cancel failed: {err}"),
2426 });
2427 }
2428 }
2429 refresh_active_task_panel(app, task_manager).await;
2430 }
2431 AppAction::Automation(action) => {
2432 crate::tui::automation_routing::handle_action(app, config, action, task_manager)
2433 .await;
2434 }
2435 AppAction::ShellJob(action) => {
2436 handle_shell_job_action(app, action);
2437 // Immediately sync the task panel after cancel/poll so the
2438 // Activity sidebar stays accurate without waiting for the
2439 // next 2.5 s periodic refresh (#2937).
2440 refresh_active_task_panel(app, task_manager).await;
2441 }
2442 AppAction::Mcp(action) => {
2443 handle_mcp_ui_action(app, engine_handle, config, action).await;
2444 }
2445 AppAction::SwitchWorkspace { workspace } => {
2446 switch_workspace(app, engine_handle, task_manager, config, workspace).await;
2447 }
2448 AppAction::SwitchProfile { profile } => {
2449 let previous_profile = app.config_profile.clone();
2450 match Config::load(app.config_path.clone(), Some(&profile)).and_then(|new_config| {
2451 validated_profile_default_route(&new_config)
2452 .map(|validated_route| (new_config, validated_route))
2453 }) {
2454 Ok((new_config, validated_route)) => {
2455 let new_model = validated_route.model.clone();
2456 apply_validated_profile_config(
2457 app,
2458 config,
2459 &profile,
2460 new_config,
2461 &validated_route,
2462 );
2463 crate::initialize_cloud_facts(config);
2464 // Rebuild the engine with the new config so API key/model/base URL take effect.
2465 let _ = engine_handle.send(Op::Shutdown).await;
2466 let engine_config = build_engine_config(app, config);
2467 *engine_handle = spawn_tui_engine(engine_config, config);
2468 if !app.api_messages.is_empty() {
2469 let _ = engine_handle
2470 .send(Op::SyncSession {
2471 session_id: app.current_session_id.clone(),
2472 messages: app.api_messages.as_ref().clone(),
2473 system_prompt: app.system_prompt.clone(),
2474 system_prompt_override: false,
2475 model: app.model.clone(),
2476 workspace: app.workspace.clone(),
2477 mode: app.mode,
2478 })
2479 .await;
2480 }
2481 app.add_message(HistoryCell::System {
2482 content: format!(
2483 "Switched to profile '{profile}'. Model: {new_model}, Provider: {}",
2484 app.provider_identity_for_persistence()
2485 ),
2486 });
2487 app.status_message = Some(format!("Profile: {profile}"));
2488 }
2489 Err(err) => {
2490 app.config_profile = previous_profile;
2491 app.status_message =
2492 Some(format!("Failed to switch to profile '{profile}': {err}"));
2493 }
2494 }
2495 }
2496 AppAction::ShareSession {
2497 history_len: _,
2498 model,
2499 mode,
2500 } => {
2501 let status = if app.api_messages.is_empty() {
2502 "No session content to share.".to_string()
2503 } else {
2504 let history_json = serde_json::to_string_pretty(&app.api_messages)
2505 .unwrap_or_else(|_| "[]".to_string());
2506 match crate::commands::share::perform_share(&history_json, &model, &mode).await
2507 {
2508 Ok(url) => format!("Session shared! URL: {url}"),
2509 Err(err) => format!("Share failed: {err}"),
2510 }
2511 };
2512 app.add_message(HistoryCell::System {
2513 content: status.clone(),
2514 });
2515 app.status_message = Some(status);
2516 }
2517 }
2518 }
2519
2520 Ok(false)
2521 }
2522
2523 /// Commit a successfully loaded profile and its validated route as one snapshot.
2524 fn apply_validated_profile_config(
2525 app: &mut App,
2526 config: &mut Config,
2527 profile: &str,
2528 next_config: Config,
2529 route: &crate::route_runtime::ValidatedRuntimeRoute,
2530 ) {
2531 *config = next_config;
2532 app.config_profile = Some(profile.to_string());
2533 app.configured_models = config.custom_models.clone().unwrap_or_default();
2534 app.refresh_notification_settings(config);
2535 app.set_provider_identity_record(route.identity.clone());
2536 app.billing_presentation = crate::route_billing::for_route(config, app.api_provider);
2537 app.set_model_selection(route.model.clone());
2538 app.set_active_context_window_override(config, app.api_provider);
2539 app.set_active_route_resolution(
2540 route.candidate.endpoint().base_url.clone(),
2541 route.candidate.limits(),
2542 route.context_window.source,
2543 );
2544 app.update_model_compaction_budget();
2545 app.session.last_prompt_tokens = None;
2546 app.session.last_completion_tokens = None;
2547 }
2548
2549 /// Open this workspace's `.codewhale/hooks.toml` in `$EDITOR`.
2550 ///
2551 /// The Hooks screen could only ever be read: it listed what was configured
2552 /// and offered no way to configure anything. Rather than grow a second
2553 /// authority over hook definitions inside the TUI, this hands the file to the
2554 /// editor the user already has, seeds it with a commented template the first
2555 /// time, and reloads the hook set on return so the screen reflects the edit
2556 /// immediately.
2557 fn edit_project_hooks_from_tui(terminal: &mut AppTerminal, app: &mut App, config: &Config) {
2558 let dir = app.workspace.join(".codewhale");
2559 let path = dir.join("hooks.toml");
2560 if !path.exists()
2561 && let Err(error) = std::fs::create_dir_all(&dir)
2562 .and_then(|()| std::fs::write(&path, crate::hooks::PROJECT_HOOKS_TEMPLATE))
2563 {
2564 app.push_status_toast(
2565 format!("Could not create {}: {error}", path.display()),
2566 StatusToastLevel::Warning,
2567 Some(8_000),
2568 );
2569 return;
2570 }
2571
2572 let outcome = crate::tui::external_editor::spawn_editor_for_path(
2573 terminal,
2574 app.use_alt_screen(),
2575 app.use_mouse_capture,
2576 app.use_bracketed_paste,
2577 &path,
2578 // Open the file, not a position in it: this edits hooks.toml whole.
2579 None,
2580 );
2581 app.needs_redraw = true;
2582
2583 match outcome {
2584 Ok(crate::tui::external_editor::EditorOutcome::Edited(_)) => {
2585 app.hooks = app.hooks.rebind(
2586 crate::hooks::HooksConfig::load_with_project_and_plugins(
2587 config.hooks_config(),
2588 &app.workspace,
2589 Some(app.plugin_registry.as_ref()),
2590 ),
2591 app.workspace.clone(),
2592 );
2593 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
2594 let reloaded = app.hooks.config();
2595 let mut content = format!(
2596 "Reloaded hooks from {} — {} configured.",
2597 path.display(),
2598 reloaded.hooks.len()
2599 );
2600 // Project hooks are executable repository configuration; an
2601 // untrusted workspace parses them and then ignores them, which is
2602 // a silent no-op unless it is said out loud.
2603 if !crate::hooks::workspace_allows_project_hooks(&app.workspace) {
2604 content.push_str(
2605 " Project hooks are not approved for these exact contents. Use /hooks review, \
2606 then /hooks approve <digest> after reviewing the commands.",
2607 );
2608 }
2609 if !reloaded.problems.is_empty() {
2610 content.push_str(&format!(
2611 " {} entr{} rejected — see the Hooks screen.",
2612 reloaded.problems.len(),
2613 if reloaded.problems.len() == 1 {
2614 "y"
2615 } else {
2616 "ies"
2617 }
2618 ));
2619 }
2620 app.add_message(HistoryCell::System { content });
2621 }
2622 Ok(crate::tui::external_editor::EditorOutcome::Unchanged) => {
2623 app.push_status_toast(
2624 "Hooks unchanged.".to_string(),
2625 StatusToastLevel::Info,
2626 Some(4_000),
2627 );
2628 }
2629 Ok(crate::tui::external_editor::EditorOutcome::Cancelled) | Err(_) => {
2630 app.push_status_toast(
2631 format!("Editor did not save {}", path.display()),
2632 StatusToastLevel::Warning,
2633 Some(6_000),
2634 );
2635 }
2636 }
2637 }
2638
2639 pub(crate) fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) {
2640 app.workspace = workspace.clone();
2641 app.coordination_detail = None;
2642 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
2643 for error in crate::commands::user_registry::install_plugin_registry(
2644 &workspace,
2645 app.plugin_registry.as_ref(),
2646 ) {
2647 tracing::warn!(target: "plugins", "{error}");
2648 }
2649 // A plugin that failed to load used to be invisible until someone
2650 // happened to open /plugin. Surface a one-line hint instead of leaving
2651 // the discovery result buried in the trace log; warnings stay quiet.
2652 let plugin_load_errors = app
2653 .plugin_registry
2654 .diagnostics()
2655 .iter()
2656 .filter(|diagnostic| {
2657 diagnostic.level == crate::plugins::types::PluginDiagnosticLevel::Error
2658 })
2659 .count();
2660 if plugin_load_errors > 0 {
2661 app.status_message = Some(if plugin_load_errors == 1 {
2662 "1 plugin failed to load — /plugin for details".to_string()
2663 } else {
2664 format!("{plugin_load_errors} plugins failed to load — /plugin for details")
2665 });
2666 }
2667 app.active_skill = None;
2668 app.active_skill_provenance = None;
2669 // Switching workspace reloads the hook set (project hooks are per-repo)
2670 // but stays inside the same TUI session, so the session id is preserved.
2671 app.hooks = app.hooks.rebind(
2672 crate::hooks::HooksConfig::load_with_project_and_plugins(
2673 config.hooks_config(),
2674 &workspace,
2675 Some(app.plugin_registry.as_ref()),
2676 ),
2677 workspace.clone(),
2678 );
2679 app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config);
2680 app.skills_scan_codewhale_only = config.skills_config().scan_codewhale_only();
2681 app.project_context_pack_enabled = config.project_context_pack_enabled();
2682 app.refresh_skill_cache();
2683 app.workspace_context = None;
2684 app.workspace_is_linked_worktree = false;
2685 if let Ok(mut cell) = app.workspace_context_cell.lock() {
2686 *cell = None;
2687 }
2688 app.workspace_context_refreshed_at = None;
2689 app.file_tree = None;
2690
2691 let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace);
2692 app.runtime_services.shell_manager = Some(shell_manager);
2693 app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
2694 }
2695
2696 pub(crate) fn apply_hotbar_setup_saved(
2697 app: &mut App,
2698 config: &mut Config,
2699 bindings: Vec<codewhale_config::HotbarBindingToml>,
2700 ) {
2701 match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &bindings)
2702 {
2703 Ok(path) => {
2704 config.hotbar = Some(bindings);
2705 app.status_message = Some(format!("Hotbar bindings saved to {}", path.display()));
2706 }
2707 Err(err) => {
2708 app.status_message = Some(format!("Failed to save Hotbar bindings: {err}"));
2709 app.add_message(HistoryCell::System {
2710 content: format!("Failed to save Hotbar bindings: {err}"),
2711 });
2712 }
2713 }
2714 app.needs_redraw = true;
2715 }
2716
2717 pub(crate) fn settle_user_input_request(app: &mut App, tool_id: &str) {
2718 app.retire_action_notices(Some(tool_id));
2719 if app
2720 .pending_user_input_prompt
2721 .as_ref()
2722 .is_some_and(|(id, _)| id == tool_id)
2723 {
2724 app.pending_user_input_prompt = None;
2725 }
2726 }
2727
2728 pub(crate) fn apply_user_input_submission_result(app: &mut App, tool_id: &str, result: Result<()>) {
2729 match result {
2730 Ok(()) => settle_user_input_request(app, tool_id),
2731 Err(error) => {
2732 tracing::warn!(tool_id, error = %error, "user input submit failed");
2733 if let Some((id, request)) = app
2734 .pending_user_input_prompt
2735 .as_ref()
2736 .filter(|(id, _)| id == tool_id)
2737 .cloned()
2738 {
2739 app.view_stack.push(UserInputView::new(id, request));
2740 }
2741 app.push_status_toast_record(
2742 StatusToast::new(
2743 app.tr(MessageId::NotificationInputSubmitFailed)
2744 .replace("{error}", &error.to_string()),
2745 StatusToastLevel::Error,
2746 Some(App::STICKY_ERROR_TTL_MS),
2747 )
2748 .for_event(format!("input-submit:{tool_id}")),
2749 );
2750 }
2751 }
2752 }
2753
2754 pub(crate) async fn apply_approval_decision(
2755 app: &mut App,
2756 engine_handle: &mut EngineHandle,
2757 config: &mut Config,
2758 event: ApprovalDecisionEvent,
2759 ) {
2760 if event.decision == ReviewDecision::ApprovedForSession {
2761 // Only the lossy grouping key is stored: a session grant is scoped to
2762 // the command family (e.g. `shell:git status`), never to the whole
2763 // tool — approving one shell command must not approve every shell
2764 // command for the session (ops R2). The tool name is recorded as
2765 // audit evidence, not as a grant.
2766 crate::audit::log_sensitive_event(
2767 "tool.approval.session_grant",
2768 serde_json::json!({
2769 "tool_name": event.tool_name,
2770 "grouping_key": event.approval_grouping_key,
2771 }),
2772 );
2773 app.approval_session_approved
2774 .insert(event.approval_grouping_key.clone());
2775 }
2776
2777 if matches!(
2778 event.decision,
2779 ReviewDecision::Approved | ReviewDecision::ApprovedForSession
2780 ) && !event.persistent_rules.is_empty()
2781 && !event.timed_out
2782 {
2783 persist_rules_from_approval(app, config, &event.persistent_rules);
2784 }
2785
2786 match event.decision {
2787 ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
2788 // Mirror mode: clear the shared-approval gate so a late web
2789 // decision acks "no longer pending" instead of double-answering.
2790 app.remote_control
2791 .resolve_pending_approval(&event.tool_id, true);
2792 if engine_handle
2793 .approve_tool_call(event.tool_id.clone())
2794 .await
2795 .is_ok()
2796 {
2797 app.retire_action_notices(Some(&event.tool_id));
2798 }
2799 }
2800 ReviewDecision::Denied => {
2801 // Cache the denial so the model retry-loop doesn't re-prompt for
2802 // the exact same approval_key (#360). Only the key (per-call
2803 // unique) is stored — NOT the tool_name, which would block all
2804 // future invocations of the same tool type (#1377).
2805 if !event.timed_out {
2806 app.approval_session_denied.insert(event.approval_key);
2807 }
2808 app.remote_control
2809 .resolve_pending_approval(&event.tool_id, false);
2810 // A bound expiry carries its own outcome (#6101) so the receipt
2811 // distinguishes "no answer within the window" from an operator
2812 // denial.
2813 let denied = if event.timed_out {
2814 engine_handle
2815 .deny_tool_call_timed_out(event.tool_id.clone())
2816 .await
2817 } else {
2818 engine_handle.deny_tool_call(event.tool_id.clone()).await
2819 };
2820 if denied.is_ok() {
2821 app.retire_action_notices(Some(&event.tool_id));
2822 }
2823 }
2824 ReviewDecision::Abort => {
2825 engine_handle.cancel();
2826 mark_active_turn_cancelled_locally(app);
2827 app.status_message = Some(parent_stop_status(app, "Request cancelled"));
2828 }
2829 }
2830 }
2831
2832 pub(crate) fn apply_setup_runtime_preset(
2833 app: &mut App,
2834 config: &mut Config,
2835 preset: crate::tui::setup::SetupRuntimePreset,
2836 state: codewhale_config::SetupState,
2837 ) -> Result<String> {
2838 if let Some(source) = config.runtime_preset_blocker(
2839 app.config_path.as_deref(),
2840 app.config_profile.as_deref(),
2841 &app.workspace,
2842 ) {
2843 anyhow::bail!(
2844 "Runtime presets cannot override {source}; change that controlling source first"
2845 );
2846 }
2847 if preset == crate::tui::setup::SetupRuntimePreset::HighTrustLocal {
2848 let approval = config.approval_policy_control(
2849 app.config_path.as_deref(),
2850 app.config_profile.as_deref(),
2851 &app.workspace,
2852 );
2853 if !approval.editable_root() {
2854 anyhow::bail!(
2855 "Full Access cannot override {}; change that controlling source first",
2856 approval.label()
2857 );
2858 }
2859 }
2860
2861 let settings_path = Settings::path().context("failed to resolve settings path")?;
2862 let settings_snapshot = RuntimePresetFileSnapshot::capture(settings_path)?;
2863 // The preset's settings read, its config-document write, and its settings
2864 // write are one durable transaction with file-snapshot rollback. Hold the
2865 // settings transaction lock across all of it so a concurrent writer (a queued
2866 // mode/thinking drain, the Shift+Tab posture write) can neither be lost by
2867 // this save nor be reverted by the rollback.
2868 // Every durable write happens inside this closure, so the settings lock is
2869 // released before live state moves below.
2870 crate::settings::with_settings_transaction(|settings_transaction| {
2871 let mut settings = settings_transaction
2872 .load()
2873 .context("failed to load settings")?;
2874 settings.default_mode = preset.default_mode().to_string();
2875 settings.permission_posture = Some(preset.permission_posture().to_string());
2876
2877 // Persist into the same file Config::load actually selected. A missing
2878 // explicit env target remains authoritative for both reads and writes;
2879 // an invalid target fails here instead of selecting a different file.
2880 let selected_config_path =
2881 crate::config::resolve_load_config_path(app.config_path.clone())?
2882 .or_else(|| app.config_path.clone());
2883 let config_path =
2884 crate::config_persistence::config_toml_path(selected_config_path.as_deref())
2885 .context("failed to resolve config path")?;
2886 let config_snapshot = RuntimePresetFileSnapshot::capture(config_path.clone())?;
2887 if let Err(error) =
2888 crate::config_persistence::mutate_config_document(&config_path, |document| {
2889 if let Some(policy) = preset.approval_policy() {
2890 crate::config_persistence::set_document_value(
2891 document,
2892 &["approval_policy"],
2893 policy,
2894 )?;
2895 } else {
2896 crate::config_persistence::unset_document_value(
2897 document,
2898 &["approval_policy"],
2899 )?;
2900 }
2901 crate::config_persistence::set_document_value(
2902 document,
2903 &["allow_shell"],
2904 preset.allow_shell(),
2905 )?;
2906 crate::config_persistence::set_document_value(
2907 document,
2908 &["sandbox_mode"],
2909 preset.sandbox_mode(),
2910 )
2911 })
2912 .context("failed to persist runtime posture")
2913 {
2914 return Err(runtime_preset_error_with_rollback(
2915 error,
2916 &[&settings_snapshot, &config_snapshot],
2917 ));
2918 }
2919 if let Err(error) = settings_transaction
2920 .save(&settings)
2921 .context("failed to save settings")
2922 {
2923 return Err(runtime_preset_error_with_rollback(
2924 error,
2925 &[&settings_snapshot, &config_snapshot],
2926 ));
2927 }
2928 if let Err(error) = state
2929 .save()
2930 .context("failed to persist setup runtime posture state")
2931 {
2932 return Err(runtime_preset_error_with_rollback(
2933 error,
2934 &[&settings_snapshot, &config_snapshot],
2935 ));
2936 }
2937 Ok(())
2938 })?;
2939
2940 // Durable writes succeeded as one transaction. Only now may live state
2941 // move to the new posture.
2942 if let Some(policy) = preset.approval_policy() {
2943 config.approval_policy = Some(policy.to_string());
2944 app.mark_approval_policy_locked();
2945 } else {
2946 config.approval_policy = None;
2947 app.clear_saved_approval_policy_lock();
2948 }
2949 config.allow_shell = Some(preset.allow_shell());
2950 config.sandbox_mode = Some(preset.sandbox_mode().to_string());
2951 app.configured_sandbox_mode = config.sandbox_mode.clone();
2952 app.configured_sandbox_network = config.sandbox_network_access;
2953
2954 let approval_mode = ApprovalMode::from_config_value(
2955 preset
2956 .approval_policy()
2957 .unwrap_or(preset.permission_posture()),
2958 )
2959 .unwrap_or(ApprovalMode::Suggest);
2960 let trust_mode = match preset {
2961 crate::tui::setup::SetupRuntimePreset::AskFirst => false,
2962 crate::tui::setup::SetupRuntimePreset::NormalAgent => app.agent_trust_baseline(),
2963 crate::tui::setup::SetupRuntimePreset::HighTrustLocal => true,
2964 };
2965 app.set_agent_runtime_baseline(preset.allow_shell(), trust_mode, approval_mode);
2966 let mode = AppMode::from_setting(preset.default_mode());
2967 app.set_mode(mode);
2968 app.needs_redraw = true;
2969
2970 Ok(format!("Applied {}.", preset.result_summary()))
2971 }
2972
2973 pub(crate) fn apply_backtrack(app: &mut App, depth: usize) {
2974 let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else {
2975 app.status_message = Some("Backtrack target no longer present".to_string());
2976 return;
2977 };
2978
2979 // Snapshot the user text before truncating so we can refill the
2980 // composer.
2981 let user_text = match app.history.get(history_idx) {
2982 Some(HistoryCell::User { content }) => content.clone(),
2983 _ => String::new(),
2984 };
2985
2986 // Trim the visible transcript at the chosen user cell. Per-cell
2987 // revisions and tool-cell maps are kept consistent through
2988 // `App::truncate_history_to`.
2989 app.truncate_history_to(history_idx);
2990
2991 // Trim the API-message log at the matching user PROMPT. `depth` counts
2992 // visible `HistoryCell::User` cells (real prompts), but a naive
2993 // `role == "user"` walk over `api_messages` over-counts: tool results are
2994 // stored as `role == "user"` messages too, so in any turn with tool calls
2995 // the cut would land mid-turn on a tool_result — leaving a dangling
2996 // assistant tool_use with no matching result and a transcript the provider
2997 // rejects. Count only messages that actually yield a User cell, the same
2998 // predicate `apply_loaded_session` uses.
2999 if let Some(idx) = backtrack_api_cut_index(&app.api_messages, depth) {
3000 app.truncate_api_messages(idx);
3001 }
3002
3003 // Hand the dropped text back to the user so they can edit + resend.
3004 app.input = user_text;
3005 app.cursor_position = app.input.chars().count();
3006
3007 // Close the overlay, refresh sticky-tail flag, and surface a hint.
3008 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
3009 app.view_stack.pop();
3010 }
3011 app.status_message =
3012 Some("Rewound to previous user message — edit and Enter to resend".to_string());
3013 app.scroll_to_bottom();
3014 app.mark_history_updated();
3015 app.needs_redraw = true;
3016 }
3017
3018 pub(crate) async fn apply_provider_picker_custom_provider(
3019 app: &mut App,
3020 engine_handle: &mut EngineHandle,
3021 config: &mut Config,
3022 provider_id: String,
3023 base_url: String,
3024 model: Option<String>,
3025 api_key_env: Option<String>,
3026 ) -> bool {
3027 let written = match crate::config_persistence::persist_custom_provider(
3028 app.config_path.as_deref(),
3029 &provider_id,
3030 &base_url,
3031 model.as_deref(),
3032 api_key_env.as_deref(),
3033 ) {
3034 Ok(path) => path,
3035 Err(err) => {
3036 app.add_message(HistoryCell::System {
3037 content: format!("Failed to save custom provider {provider_id}: {err}"),
3038 });
3039 app.status_message = Some("Custom provider was not saved.".to_string());
3040 return false;
3041 }
3042 };
3043
3044 config.provider = Some(provider_id.clone());
3045 let entry = config
3046 .providers
3047 .get_or_insert_with(ProvidersConfig::default)
3048 .custom
3049 .entry(provider_id.clone())
3050 .or_default();
3051 entry.kind = Some("openai-compatible".to_string());
3052 entry.base_url = Some(base_url.trim().trim_end_matches('/').to_string());
3053 if provider_id == "ds4" && crate::config::base_url_uses_local_host(&base_url) {
3054 entry.context_window = Some(100_000);
3055 }
3056 entry.model = model.clone().and_then(|value| {
3057 let value = value.trim().to_string();
3058 (!value.is_empty()).then_some(value)
3059 });
3060 let keyless_local = provider_id == "ds4"
3061 && api_key_env
3062 .as_deref()
3063 .is_none_or(|value| value.trim().is_empty())
3064 && crate::config::base_url_uses_local_host(&base_url);
3065 entry.api_key_env = api_key_env.and_then(|value| {
3066 let value = value.trim().to_string();
3067 (!value.is_empty()).then_some(value)
3068 });
3069 entry.auth_mode = keyless_local.then(|| "none".to_string());
3070
3071 app.status_message = Some(format!(
3072 "Custom provider {provider_id} saved to {}",
3073 written.display()
3074 ));
3075 switch_provider(app, engine_handle, config, ApiProvider::Custom, model).await
3076 }
3077
3078 async fn reopen_provider_picker_list(
3079 app: &mut App,
3080 engine_handle: &mut EngineHandle,
3081 config: &Config,
3082 selected_provider_id: Option<String>,
3083 catalog_view: bool,
3084 ) {
3085 let runtime_status = query_provider_runtime_status(engine_handle).await;
3086 app.provider_picker_memory = Some(crate::tui::app::ProviderPickerMemory {
3087 catalog_view,
3088 selected_provider_id,
3089 });
3090 app.view_stack.push(
3091 crate::tui::provider_picker::ProviderPickerView::new_with_runtime_status_and_memory(
3092 app.api_provider,
3093 config,
3094 runtime_status,
3095 app.provider_picker_memory.as_ref(),
3096 )
3097 .with_locale(app.ui_locale)
3098 .with_provider_health(&app.provider_health),
3099 );
3100 app.needs_redraw = true;
3101 }
3102
3103 pub(crate) async fn apply_provider_picker_test_connection(
3104 app: &mut App,
3105 engine_handle: &mut EngineHandle,
3106 config: &mut Config,
3107 identity: crate::config::ProviderIdentity,
3108 catalog_view: bool,
3109 ) {
3110 apply_provider_picker_test_connection_with_verifier(
3111 app,
3112 engine_handle,
3113 config,
3114 identity,
3115 catalog_view,
3116 &LiveProviderKeyVerifier,
3117 )
3118 .await;
3119 }
3120
3121 fn sanitize_probe_status(reason: &str, api_key: &str) -> String {
3122 let mut text = reason.to_string();
3123 if let Some(rest) = reason.strip_prefix("HTTP ")
3124 && let Some((code, body)) = rest.split_once(':')
3125 && let Ok(status) = code.trim().parse::<u16>()
3126 {
3127 text = crate::llm_client::sanitize_http_error_body(None, status, body.trim());
3128 }
3129 let secret = api_key.trim();
3130 if !secret.is_empty() {
3131 text = text.replace(secret, "***");
3132 }
3133 crate::utils::truncate_with_ellipsis(text.trim(), 120, "…")
3134 }
3135
3136 pub(crate) async fn apply_provider_picker_test_connection_with_verifier(
3137 app: &mut App,
3138 engine_handle: &mut EngineHandle,
3139 config: &mut Config,
3140 identity: crate::config::ProviderIdentity,
3141 catalog_view: bool,
3142 verifier: &dyn ProviderKeyVerifier,
3143 ) {
3144 let provider = identity.provider;
3145 let mut scoped_config = config.clone();
3146 scoped_config.provider = Some(identity.key.clone());
3147 let selected_id = if provider == ApiProvider::Custom {
3148 Some(identity.key.clone())
3149 } else {
3150 Some(provider.as_str().to_string())
3151 };
3152 if !crate::client::provider_api_key_verification_is_observed(provider) {
3153 app.push_status_toast(
3154 app.tr(MessageId::ProviderTestConnectionNoEndpoint)
3155 .replace("{provider}", &identity.key),
3156 StatusToastLevel::Warning,
3157 Some(8_000),
3158 );
3159 reopen_provider_picker_list(app, engine_handle, config, selected_id, catalog_view).await;
3160 return;
3161 }
3162 let api_key = match scoped_config.active_route_api_key_read_only() {
3163 Ok(key) if !key.trim().is_empty() => key,
3164 _ => {
3165 app.push_status_toast(
3166 app.tr(MessageId::ProviderTestConnectionNeedKey)
3167 .replace("{provider}", &identity.key),
3168 StatusToastLevel::Warning,
3169 Some(8_000),
3170 );
3171 reopen_provider_picker_list(app, engine_handle, config, selected_id, catalog_view)
3172 .await;
3173 return;
3174 }
3175 };
3176 let base_url = scoped_config.active_route_base_url();
3177 let model = scoped_config.default_model();
3178 match verifier.verify(provider, &api_key, &base_url).await {
3179 Ok(()) => {
3180 app.provider_health
3181 .record_models_probe_success(&scoped_config, provider, &model);
3182 app.push_status_toast(
3183 app.tr(MessageId::ProviderConnectionChecked).into_owned(),
3184 StatusToastLevel::Success,
3185 Some(8_000),
3186 );
3187 }
3188 Err(reason) => {
3189 let safe = sanitize_probe_status(&reason, &api_key);
3190 app.provider_health.record_models_probe_failure(
3191 &scoped_config,
3192 provider,
3193 &model,
3194 provider_verification_error_category(&reason),
3195 &safe,
3196 );
3197 app.push_status_toast(
3198 app.tr(MessageId::ProviderTestConnectionFailed)
3199 .replace("{provider}", &identity.key)
3200 .replace("{error}", &safe),
3201 StatusToastLevel::Error,
3202 Some(8_000),
3203 );
3204 }
3205 }
3206 reopen_provider_picker_list(app, engine_handle, config, selected_id, catalog_view).await;
3207 }
3208
3209 pub(crate) async fn apply_provider_picker_api_key(
3210 app: &mut App,
3211 engine_handle: &mut EngineHandle,
3212 config: &mut Config,
3213 identity: crate::config::ProviderIdentity,
3214 api_key: String,
3215 base_url: Option<String>,
3216 ) {
3217 apply_provider_picker_api_key_with_verifier(
3218 app,
3219 engine_handle,
3220 config,
3221 identity,
3222 api_key,
3223 base_url,
3224 &LiveProviderKeyVerifier,
3225 )
3226 .await;
3227 }
3228
3229 pub(crate) async fn apply_provider_picker_api_key_with_verifier(
3230 app: &mut App,
3231 engine_handle: &mut EngineHandle,
3232 config: &mut Config,
3233 identity: crate::config::ProviderIdentity,
3234 api_key: String,
3235 base_url_override: Option<String>,
3236 verifier: &dyn ProviderKeyVerifier,
3237 ) {
3238 let provider = identity.provider;
3239 let mut scoped_config = config.clone();
3240 scoped_config.provider = Some(identity.key.clone());
3241 // #4526: a billing route chosen in the wizard is applied to the scoped
3242 // clone only, so the key is probed against the endpoint it will be saved
3243 // for without touching the on-disk config before the user confirms.
3244 if let Some(base_url) = base_url_override.clone() {
3245 scoped_config.set_provider_base_url_override(provider, Some(base_url));
3246 }
3247 // #3875: verify the key against the provider before opening the rest of
3248 // the guided flow. Nothing is persisted until the confirm stage.
3249 // Resolve the effective route, including compatibility routes whose
3250 // endpoint is selected by auth mode (notably a legacy Kimi CLI import).
3251 // This prevents a replacement Kimi Code API key from being probed against
3252 // the ordinary Moonshot endpoint.
3253 let base_url = scoped_config.active_route_base_url();
3254 match verifier.verify(provider, &api_key, &base_url).await {
3255 Ok(()) => {
3256 // Keep the readiness row aligned with the live check the wizard
3257 // just completed. This probe only proves the endpoint and
3258 // credentials are reachable: the model is chosen after the probe,
3259 // so record a distinct connection-checked state rather than
3260 // claiming the model is ready. Providers without a real `/models`
3261 // probe remain unchecked.
3262 if crate::client::provider_api_key_verification_is_observed(provider) {
3263 let verified_model = scoped_config.default_model();
3264 app.provider_health.record_models_probe_success(
3265 &scoped_config,
3266 provider,
3267 &verified_model,
3268 );
3269 }
3270 // Key is valid — continue the guided flow at model pick without
3271 // writing the secret yet.
3272 let runtime_status = query_provider_runtime_status(engine_handle).await;
3273 if let Some(picker) =
3274 crate::tui::provider_picker::ProviderPickerView::new_for_model_pick_after_validation(
3275 app.api_provider,
3276 provider,
3277 &scoped_config,
3278 runtime_status,
3279 api_key,
3280 base_url_override,
3281 )
3282 .map(|picker| {
3283 picker
3284 .with_locale(app.ui_locale)
3285 .with_provider_health(&app.provider_health)
3286 })
3287 {
3288 app.view_stack.push(picker);
3289 app.status_message = Some(
3290 app.tr(MessageId::ProviderConnectionCheckedPickModel)
3291 .into_owned(),
3292 );
3293 } else {
3294 app.status_message = Some(format!(
3295 "{} connection checked (/models returned 2xx), but the guided setup could not be re-opened.",
3296 provider.as_str()
3297 ));
3298 }
3299 app.needs_redraw = true;
3300 }
3301 Err(reason) => {
3302 // Verification failed - keep the picker open at the key-entry
3303 // stage with the provider's actual error so the user can fix
3304 // the key instead of dead-ending with a status toast.
3305 let runtime_status = query_provider_runtime_status(engine_handle).await;
3306 if let Some(picker) =
3307 crate::tui::provider_picker::ProviderPickerView::new_for_key_entry_with_error(
3308 app.api_provider,
3309 provider,
3310 &scoped_config,
3311 runtime_status,
3312 reason,
3313 )
3314 .map(|picker| {
3315 picker
3316 .with_locale(app.ui_locale)
3317 .with_provider_health(&app.provider_health)
3318 })
3319 {
3320 app.view_stack.push(picker);
3321 app.status_message = Some(format!(
3322 "{} API key verification failed - check the key and try again.",
3323 provider.as_str()
3324 ));
3325 } else {
3326 app.status_message = Some(format!(
3327 "{} API key verification failed, but the provider could not be re-opened.",
3328 provider.as_str()
3329 ));
3330 }
3331 app.needs_redraw = true;
3332 }
3333 }
3334 }
3335
3336 #[allow(clippy::too_many_arguments)]
3337 pub(crate) async fn apply_provider_picker_setup_confirmed(
3338 app: &mut App,
3339 engine_handle: &mut EngineHandle,
3340 config: &mut Config,
3341 identity: crate::config::ProviderIdentity,
3342 api_key: String,
3343 model: String,
3344 context_window: Option<u32>,
3345 base_url: Option<String>,
3346 ) -> bool {
3347 use crate::config::{
3348 save_api_key_for_identity, save_provider_base_url_for_identity,
3349 save_provider_context_window_for_identity, save_provider_model_for_identity,
3350 };
3351
3352 let provider = identity.provider;
3353
3354 let model = model.trim().to_string();
3355 if model.is_empty() {
3356 app.add_message(HistoryCell::System {
3357 content: format!(
3358 "Cannot finish {} setup: default model is empty.\nProvider unchanged.",
3359 provider.as_str()
3360 ),
3361 });
3362 return false;
3363 }
3364
3365 // #4526: the wizard's billing-route choice is written before the key so the
3366 // credential is saved onto the route it was verified against. It lands only
3367 // in that provider's own `base_url`; failing here aborts before any secret
3368 // is persisted rather than leaving a key on the wrong endpoint.
3369 if let Some(base_url) = base_url.as_deref() {
3370 if let Err(err) = save_provider_base_url_for_identity(&identity, config, base_url) {
3371 app.add_message(HistoryCell::System {
3372 content: format!(
3373 "Failed to save {} endpoint `{base_url}`: {err}\nProvider unchanged.",
3374 provider.as_str()
3375 ),
3376 });
3377 return false;
3378 }
3379 config.set_provider_base_url_override(provider, Some(base_url.to_string()));
3380 }
3381
3382 // Persist key first via the existing comment-preserving path, then pin the
3383 // chosen default model on the same document when the provider uses a
3384 // `[providers.<name>]` table.
3385 let mut save_confirmation = None;
3386 match save_api_key_for_identity(&identity, config, &api_key) {
3387 Ok(saved) => {
3388 // #5195: name where the key actually landed (secret store backend
3389 // + credential-free config metadata) and the scope it is visible
3390 // from — credential writes are rescoped to the user-global config,
3391 // so the key is available in every folder.
3392 let destination = saved.describe();
3393 if let Err(err) = save_provider_model_for_identity(&identity, config, &model) {
3394 app.add_message(HistoryCell::System {
3395 content: format!(
3396 "Saved {} API key to {destination} (available in all folders), but failed to pin model `{model}`: {err}",
3397 provider.as_str(),
3398 ),
3399 });
3400 } else if let Some(context_window) = context_window {
3401 if let Err(err) =
3402 save_provider_context_window_for_identity(&identity, config, context_window)
3403 {
3404 app.add_message(HistoryCell::System {
3405 content: format!(
3406 "Saved {} API key and model to {destination} (available in all folders), but failed to save context window: {err}",
3407 provider.as_str(),
3408 ),
3409 });
3410 } else {
3411 save_confirmation = Some(format!(
3412 "Saved {} API key, model, and context window to {destination} (available in all folders)",
3413 provider.as_str(),
3414 ));
3415 }
3416 } else {
3417 save_confirmation = Some(format!(
3418 "Saved {} API key and model to {destination} (available in all folders)",
3419 provider.as_str(),
3420 ));
3421 }
3422 app.api_key_env_only = false;
3423 }
3424 Err(err) => {
3425 app.add_message(HistoryCell::System {
3426 content: format!(
3427 "Failed to save {} API key: {err}\nProvider unchanged.",
3428 provider.as_str()
3429 ),
3430 });
3431 return false;
3432 }
3433 }
3434
3435 config.provider = Some(identity.key);
3436 mirror_saved_api_key_in_config(config, provider, api_key);
3437 mirror_saved_model_in_config(config, provider, model.clone());
3438 if let Some(context_window) = context_window {
3439 mirror_saved_context_window_in_config(config, provider, context_window);
3440 }
3441 let switched = switch_provider(app, engine_handle, config, provider, Some(model)).await;
3442 // The switch overwrites the status line with the route summary (the full
3443 // summary also lands in the transcript), so the save confirmation is
3444 // applied last — it is the answer to the action the user just confirmed.
3445 if switched && let Some(confirmation) = save_confirmation {
3446 app.status_message = Some(confirmation);
3447 }
3448 switched
3449 }
3450
3451 async fn apply_codewhale_owned_login(
3452 app: &mut App,
3453 engine_handle: &mut EngineHandle,
3454 config: &mut Config,
3455 provider: ApiProvider,
3456 pending: crate::oauth::PendingOAuthLogin,
3457 status_prefix: &str,
3458 login_kind: &str,
3459 ) -> bool {
3460 match crate::oauth::activate_login(pending, app.config_path.as_deref(), Some(&mut *config)) {
3461 Ok(activation) => {
3462 app.status_message = Some(format!(
3463 "{status_prefix}; activated {} via {}",
3464 codewhale_config::quote_os_path(&activation.auth_path),
3465 codewhale_config::quote_os_path(&activation.config_path)
3466 ));
3467 app.api_key_env_only = false;
3468 }
3469 Err(err) => {
3470 app.add_message(HistoryCell::System {
3471 content: format!(
3472 "Failed to finalize {} {login_kind}: {err:#}\nProvider unchanged.",
3473 provider.as_str()
3474 ),
3475 });
3476 return false;
3477 }
3478 }
3479
3480 switch_provider(app, engine_handle, config, provider, None).await
3481 }
3482
3483 pub(crate) async fn apply_codewhale_owned_xai_login(
3484 app: &mut App,
3485 engine_handle: &mut EngineHandle,
3486 config: &mut Config,
3487 pending: crate::oauth::PendingOAuthLogin,
3488 status_prefix: &str,
3489 ) -> bool {
3490 apply_codewhale_owned_login(
3491 app,
3492 engine_handle,
3493 config,
3494 ApiProvider::Xai,
3495 pending,
3496 status_prefix,
3497 "device login",
3498 )
3499 .await
3500 }
3501
3502 pub(crate) async fn apply_codewhale_owned_chatgpt_login(
3503 app: &mut App,
3504 engine_handle: &mut EngineHandle,
3505 config: &mut Config,
3506 pending: crate::oauth::PendingOAuthLogin,
3507 status_prefix: &str,
3508 ) -> bool {
3509 apply_codewhale_owned_login(
3510 app,
3511 engine_handle,
3512 config,
3513 ApiProvider::OpenaiCodex,
3514 pending,
3515 status_prefix,
3516 "ChatGPT sign-in",
3517 )
3518 .await
3519 }
3520
3521 /// `/auth chatgpt-revoke`. The remote revoke is one blocking HTTP round trip
3522 /// per stored token under the OAuth lifecycle lock, so it runs on the blocking
3523 /// pool instead of the event loop. It targets the session's own config file
3524 /// and clears the live route afterwards so the header stops claiming OAuth.
3525 pub(crate) async fn run_chatgpt_revoke_from_tui(app: &mut App, config: &mut Config) {
3526 let config_path = app.config_path.clone();
3527 let outcome = tokio::task::spawn_blocking(move || {
3528 crate::oauth::revoke_owned_login(
3529 crate::oauth::OAuthProvider::Chatgpt,
3530 config_path.as_deref(),
3531 None,
3532 )
3533 })
3534 .await
3535 .map_err(|err| anyhow::anyhow!("ChatGPT revoke task was lost: {err}"))
3536 .and_then(|result| result);
3537 let message = match outcome {
3538 Ok(()) => {
3539 config.clear_codewhale_owned_chatgpt_oauth();
3540 "Revoked Codewhale-owned ChatGPT tokens. Codex CLI consent is unchanged.".to_string()
3541 }
3542 Err(err) => format!("ChatGPT revoke failed: {err:#}"),
3543 };
3544 app.add_message(HistoryCell::System {
3545 content: message.clone(),
3546 });
3547 app.status_message = Some(message);
3548 app.needs_redraw = true;
3549 }
3550
3551 #[cfg(test)]
3552 pub(crate) fn apply_loaded_session(
3553 app: &mut App,
3554 config: &mut Config,
3555 session: &SavedSession,
3556 ) -> Result<(), String> {
3557 apply_loaded_session_with_goal(app, config, session, None)
3558 }
3559
3560 pub(crate) fn apply_loaded_session_with_goal(
3561 app: &mut App,
3562 config: &mut Config,
3563 session: &SavedSession,
3564 goal: Option<&crate::session_manager::SessionGoalState>,
3565 ) -> Result<(), String> {
3566 let mut recovered_binding = None;
3567 if let Some(binding) = session.metadata.runtime_store.as_ref()
3568 && let Some(tasks) = app.runtime_services.task_manager.as_ref()
3569 && tasks.session_store_binding().as_ref() != Some(binding)
3570 {
3571 // A switch can rebind the conversation but cannot carry the saved
3572 // store's durable work into the running host, so it may only adopt a
3573 // store there is nothing to lose from leaving: one that is missing, or
3574 // one that exists and is provably empty *and* provably unheld, with no
3575 // scope-pinned automation. A force-quit leaves the second shape — the
3576 // store is on disk, ownerless and holding zero events — and refusing
3577 // it protected nothing while making the session unopenable (#6207).
3578 let nothing_to_abandon = binding
3579 .is_missing_session_store()
3580 .map_err(|error| error.to_string())?
3581 || binding
3582 .is_adoptable_empty_store()
3583 .map_err(|error| error.to_string())?;
3584 if nothing_to_abandon {
3585 recovered_binding = tasks.session_store_binding();
3586 }
3587 if recovered_binding.is_none() {
3588 // Name the real condition and the path that actually works. The
3589 // old wording ("resume it in a new Codewhale process") sent users
3590 // in circles: starting a new process and then picking the session
3591 // from `/resume` lands here again, because that is this same
3592 // switch path. Opening the session *at launch* is a different
3593 // route — `TaskManager::start` passes the saved binding through to
3594 // `open_for_session`, which validates the existing store and
3595 // adopts it (runtime_threads.rs, `validate_existing_store` then
3596 // `open_inner`). So the advice has to say which one (#6207, #6225).
3597 return Err(format!(
3598 "This session's saved Runtime store belongs to a different host. \
3599 Switching to it from inside a running session cannot carry that \
3600 store's queued work across, but opening it directly can: run \
3601 `codewhale resume {}` from your shell.",
3602 session.metadata.id
3603 ));
3604 }
3605 }
3606 if app.session_transition_blocked() {
3607 return Err(
3608 "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(),
3609 );
3610 }
3611 if let Some(goal) = goal {
3612 goal.validate()
3613 .map_err(|error| format!("saved session goal is invalid: {error}"))?;
3614 }
3615 let provider_identity = config.resolve_persisted_provider_identity(
3616 Some(&session.metadata.model_provider),
3617 session.metadata.model_provider_id.as_deref(),
3618 )?;
3619 let restored_route = resolve_runtime_route_for_identity(
3620 config,
3621 &provider_identity,
3622 Some(&session.metadata.model),
3623 )
3624 .map_err(|reason| {
3625 format!(
3626 "saved session provider '{}' could not be resolved from the live config: {reason}. Codewhale will not fall back",
3627 provider_identity.key
3628 )
3629 })?;
3630 // Restore/validate the contended state before mutating conversation or
3631 // workspace fields. A failed session switch must leave the current session
3632 // wholly intact.
3633 let queue_transition = prepare_offline_queue_transition(app, &session.metadata.id)?;
3634 if let Some(binding) = recovered_binding.as_ref() {
3635 // Only the conversation is recovered into this idle host. Its missing
3636 // runtime's tasks and approvals are never imported or re-admitted.
3637 // Repair its binding before changing live Work state. If Work restore
3638 // is contended, the current conversation stays intact and a retry can
3639 // use this durably repaired binding to the same host.
3640 let mut recovered = session.clone();
3641 recovered.metadata.runtime_store = Some(binding.clone());
3642 SessionManager::default_location()
3643 .and_then(|manager| manager.save_session(&recovered))
3644 .map_err(|error| format!("Session recovery could not be saved: {error}"))?;
3645 }
3646 app.restore_work_state(
3647 &session.metadata.id,
3648 &session.metadata.workspace,
3649 session.work_state.as_ref(),
3650 )?;
3651 install_offline_queue_transition(app, queue_transition);
3652 // All fallible preflight is complete. Retire the old session's background
3653 // accounting atomically before mutating live state; any late old-scope
3654 // provider response is rejected by `cost_status::report`.
3655 let _settled_old_cost_scope = crate::cost_status::close_current_scope();
3656 *config = *restored_route.config;
3657 app.refresh_notification_settings(config);
3658 app.restore_api_messages(
3659 crate::runtime_handoff::project_messages_for_restore(&session.messages),
3660 session,
3661 );
3662 app.clear_history();
3663 app.tool_cells.clear();
3664 app.tool_details_by_cell.clear();
3665 app.active_cell = None;
3666 app.active_tool_details.clear();
3667 app.active_tool_entry_completed_at.clear();
3668 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
3669 app.exploring_cell = None;
3670 app.exploring_entries.clear();
3671 app.ignored_tool_calls.clear();
3672 app.pending_tool_uses.clear();
3673 app.last_exec_wait_command = None;
3674 let messages = app.api_messages.clone();
3675 let mut message_to_cell = std::collections::HashMap::new();
3676 for (message_index, msg) in messages.iter().enumerate() {
3677 let mut cells = history_cells_from_message(msg);
3678 if msg.role == "user"
3679 && session
3680 .context_references
3681 .iter()
3682 .any(|record| record.message_index == message_index)
3683 {
3684 for cell in &mut cells {
3685 if let HistoryCell::User { content } = cell {
3686 *content = compact_user_context_display(content);
3687 }
3688 }
3689 }
3690 let base = app.history.len();
3691 if msg.role == "user"
3692 && let Some(offset) = cells
3693 .iter()
3694 .position(|cell| matches!(cell, HistoryCell::User { .. }))
3695 {
3696 message_to_cell.insert(message_index, base + offset);
3697 }
3698 app.extend_history(cells);
3699 }
3700 app.rebuild_completed_assistant_outputs_from_restored_history();
3701 app.sync_context_references_from_session(&session.context_references, &message_to_cell);
3702 app.mark_history_updated();
3703 app.viewport.transcript_selection.clear();
3704 // Goal state is session-owned just like Work state. A legacy/no-goal
3705 // session clears the previous session's objective; a durable sidecar
3706 // rebuilds both the visible hunt and the EngineConfig seeded below.
3707 app.goal = crate::tui::app::HostGoalState::default();
3708 app.last_known_goal_state = None;
3709 app.pending_goal_controls.clear();
3710 if let Some(goal) = goal {
3711 let snapshot = goal.to_runtime_snapshot();
3712 let _ = apply_goal_snapshot_to_app(app, &snapshot);
3713 }
3714 restore_loaded_session_provider(app, config, provider_identity);
3715 // Session records do not own a reasoning preference. `set_model_selection`
3716 // restores the raw explicit global preference for Auto (or releases an
3717 // implicit fixed-route default) instead of reusing normalized live state.
3718 app.set_model_selection(session.metadata.model.clone());
3719 if app.auto_model
3720 && let Some(saved) = session.last_auto_route.as_ref()
3721 && !saved.provider_identity.trim().is_empty()
3722 && !saved.model.trim().is_empty()
3723 {
3724 app.last_effective_provider = Some(saved.provider);
3725 app.last_effective_provider_identity = Some(saved.provider_identity.clone());
3726 app.last_effective_model = Some(saved.model.clone());
3727 app.last_auto_route_receipt = Some(saved.receipt.clone());
3728 app.last_effective_reasoning_effort = saved.effective_reasoning_effort.map(Into::into);
3729 }
3730 resolve_loaded_session_route(app, config);
3731 if !app.auto_model {
3732 let requested = app
3733 .reasoning_effort_preference
3734 .unwrap_or(app.reasoning_effort);
3735 app.reasoning_effort =
3736 requested.normalize_for_route(app.api_provider, &app.active_route_base_url, &app.model);
3737 }
3738 app.provider_models.insert(
3739 app.provider_identity_for_persistence().to_string(),
3740 app.model_selection_for_persistence(),
3741 );
3742 app.update_model_compaction_budget();
3743 apply_workspace_runtime_state(app, config, session.metadata.workspace.clone());
3744 if let Some(mode) = session.metadata.mode.as_deref().and_then(AppMode::parse) {
3745 app.set_mode(mode);
3746 }
3747 app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
3748 app.session.total_conversation_tokens = app.session.total_tokens;
3749 let restored_parent = crate::pricing::CostEstimate {
3750 usd: session.metadata.cost.session_cost_usd,
3751 cny: session.metadata.cost.session_cost_cny,
3752 }
3753 .sanitized();
3754 let restored_background = crate::pricing::CostEstimate {
3755 usd: session.metadata.cost.subagent_cost_usd,
3756 cny: session.metadata.cost.subagent_cost_cny,
3757 }
3758 .sanitized();
3759 // A restored session has no live billed receipt; the estimate rules the
3760 // meter until the next model call reports one.
3761 app.last_billed_input_tokens = None;
3762 app.session.session_cost = restored_parent.usd;
3763 app.session.session_cost_cny = restored_parent.cny;
3764 app.session.subagent_cost = restored_background.usd;
3765 app.session.subagent_cost_cny = restored_background.cny;
3766 app.session.subagent_usage_sources = session
3767 .metadata
3768 .cost
3769 .usage_source_fingerprints
3770 .iter()
3771 .cloned()
3772 .collect();
3773 crate::cost_status::restore_usage_source_fingerprints(
3774 session
3775 .metadata
3776 .cost
3777 .usage_source_fingerprints
3778 .iter()
3779 .cloned(),
3780 );
3781 // Coverage is restored *with* the money, and the live counters are cleared
3782 // first: whatever the previous session in this process priced is not inside
3783 // the total being loaded, so carrying those counters over would describe the
3784 // wrong total (#4318).
3785 app.reset_cost_coverage();
3786 app.session.cost_priced_turns = session.metadata.cost.priced_turns;
3787 app.session.cost_unpriced_turns = session.metadata.cost.unpriced_turns;
3788 app.session.cost_cny_priced_turns = session.metadata.cost.cny_priced_turns;
3789 app.session.cost_cny_unpriced_turns = session.metadata.cost.cny_unpriced_turns;
3790 app.session.cost_unpriced_reasons = session.metadata.cost.unpriced_reasons.clone();
3791 app.session.cost_cny_unpriced_reasons = session.metadata.cost.cny_unpriced_reasons.clone();
3792 app.session.cost_unpriced_classes = session.metadata.cost.unpriced_classes.clone();
3793 app.session.cost_pricing_provenances = session.metadata.cost.pricing_provenances.clone();
3794 app.session.cost_live_pricing_defects = session.metadata.cost.live_pricing_defects.clone();
3795 app.session.cost_live_pricing_unusable_defects =
3796 session.metadata.cost.live_pricing_unusable_defects.clone();
3797 app.session.cost_route_receipts = session.metadata.cost.route_receipts.clone();
3798 // A pre-coverage session deserializes its new fields from serde defaults,
3799 // which are indistinguishable from "complete total, zero turns". Flag it so
3800 // `/cost` says the coverage is unknown rather than claiming completeness,
3801 // including for an all-zero record.
3802 app.session.cost_coverage_unknown_legacy = session.metadata.cost.coverage_is_legacy_unknown();
3803 // Restore the high-water marks from persisted metadata so the
3804 // monotonic cost guarantee (#244) survives session restarts.
3805 // Take the max with the current totals — old sessions without
3806 // persisted high-water fields deserialise to 0.0 and fall back to
3807 // the restored total with no regression.
3808 let total_restored_usd = session.metadata.cost.total_usd();
3809 let total_restored_cny = session.metadata.cost.total_cny();
3810 let restored_high_water = crate::pricing::CostEstimate {
3811 usd: session.metadata.cost.displayed_cost_high_water_usd,
3812 cny: session.metadata.cost.displayed_cost_high_water_cny,
3813 }
3814 .sanitized();
3815 app.session.displayed_cost_high_water = restored_high_water.usd.max(total_restored_usd);
3816 app.session.displayed_cost_high_water_cny = restored_high_water.cny.max(total_restored_cny);
3817 app.session.last_prompt_tokens = None;
3818 app.session.last_completion_tokens = None;
3819 app.session.last_prompt_cache_hit_tokens = None;
3820 app.session.last_prompt_cache_miss_tokens = None;
3821 app.session.last_reasoning_replay_tokens = None;
3822 // Accumulated token breakdown is per-runtime-session; reset on load.
3823 app.session.reset_token_breakdown();
3824 // The metrics strip shares that scope: it describes this runtime
3825 // session's calls, not the restored transcript's.
3826 app.session_metrics = crate::tui::session_metrics::SessionMetrics::default();
3827 app.session.turn_cache_history.clear();
3828 // Restore cumulative turn duration so the footer "worked" chip
3829 // persists across session restarts (#2038).
3830 app.cumulative_turn_duration =
3831 std::time::Duration::from_secs(session.metadata.cumulative_turn_secs);
3832 app.current_session_id = Some(session.metadata.id.clone());
3833 app.current_session_metadata = Some(session.metadata.clone());
3834 if let Some(binding) = recovered_binding {
3835 if let Some(metadata) = app.current_session_metadata.as_mut() {
3836 metadata.runtime_store = Some(binding);
3837 }
3838 app.push_status_toast(
3839 app.tr(MessageId::RuntimeStoreRecovered).into_owned(),
3840 StatusToastLevel::Warning,
3841 None,
3842 );
3843 }
3844 app.session_artifacts = session.artifacts.clone();
3845 app.session_title = Some(session.metadata.title.clone());
3846 app.window_title = session.window_title.clone();
3847 app.workspace_context = None;
3848 app.workspace_is_linked_worktree = false;
3849 app.workspace_context_refreshed_at = None;
3850 if let Some(sp) = session.system_prompt.as_ref() {
3851 app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
3852 } else {
3853 app.system_prompt = None;
3854 }
3855 app.scroll_to_bottom();
3856 Ok(())
3857 }
3858
3859 pub(crate) fn apply_loaded_session_config_snapshot(
3860 app: &mut App,
3861 config: &mut Config,
3862 session: &SavedSession,
3863 mut next_config: Config,
3864 force_engine_respawn: bool,
3865 ) -> Result<bool, String> {
3866 if force_engine_respawn {
3867 // File `/load` supplies a freshly loaded disk snapshot, but the live
3868 // Config also contains CLI and workspace/project overlays that are not
3869 // represented by that file. Refresh the provider registry atomically
3870 // over the effective Config instead of dropping permission controls.
3871 let mut effective_config = config.clone();
3872 effective_config.refresh_provider_routes_from(&next_config);
3873 next_config = effective_config;
3874 }
3875 let previous_provider = app.api_provider;
3876 let previous_provider_identity = app.provider_identity_for_persistence().to_string();
3877 let previous_workspace = app.workspace.clone();
3878 let goal = SessionManager::default_location()
3879 .and_then(|manager| manager.load_session_goal(&session.metadata.id))
3880 .map_err(|error| format!("saved session goal could not be loaded: {error}"))?;
3881 apply_loaded_session_with_goal(app, &mut next_config, session, goal.as_ref())?;
3882 // A file load reads a fresh disk snapshot. Even when the route's enum and
3883 // exact identity are unchanged, endpoint, key, headers, TLS, or retry
3884 // settings may have changed. Rebuild from that same validated snapshot so
3885 // compaction and other pre-turn engine work cannot retain the old client.
3886 let respawn = force_engine_respawn
3887 || loaded_session_requires_engine_respawn(
3888 app,
3889 previous_provider,
3890 &previous_provider_identity,
3891 &previous_workspace,
3892 );
3893 *config = next_config;
3894 app.configured_models = config.custom_models.clone().unwrap_or_default();
3895 crate::initialize_cloud_facts(config);
3896 app.refresh_notification_settings(config);
3897 Ok(respawn)
3898 }
3899
3900 #[cfg(test)]
3901 mod profile_snapshot_tests {
3902 use super::*;
3903
3904 fn profile_fixture(model: &str, base_url: &str) -> Config {
3905 let mut config: Config = toml::from_str(include_str!(
3906 "../../../../config/tests/fixtures/custom_models.toml"
3907 ))
3908 .expect("profile fixture");
3909 config.api_key = Some("profile-snapshot-local-fixture".to_string());
3910 config.default_text_model = Some(model.to_string());
3911 config.providers.as_mut().unwrap().deepseek.base_url = Some(base_url.to_string());
3912 let declaration = &mut config.custom_models.as_mut().unwrap()[0];
3913 declaration.id = model.to_string();
3914 declaration.base_url = base_url.to_string();
3915 config
3916 }
3917
3918 #[test]
3919 fn profile_switch_replaces_metadata_and_validated_route_snapshot() {
3920 std::thread::Builder::new()
3921 .stack_size(16 * 1024 * 1024)
3922 .spawn(|| {
3923 let _env = crate::test_support::lock_test_env();
3924 let home = tempfile::tempdir().unwrap();
3925 let _home = crate::test_support::EnvVarGuard::set(
3926 "CODEWHALE_HOME",
3927 home.path().as_os_str(),
3928 );
3929 let mut config = profile_fixture("old-preview", "https://old.example.test/v1");
3930 let mut options = crate::test_support::test_tui_options(home.path());
3931 options.model = config.default_model();
3932 let mut app = App::new(options, &config);
3933 assert_eq!(app.configured_models[0].id, "old-preview");
3934
3935 let next = profile_fixture("new-preview", "https://new.example.test/v1");
3936 let route = validated_profile_default_route(&next).unwrap();
3937 assert_eq!(
3938 route.context_window.source,
3939 crate::route_runtime::ContextWindowSource::UserDeclared,
3940 );
3941 let expected_models = next.custom_models.clone().unwrap();
3942 apply_validated_profile_config(&mut app, &mut config, "new", next, &route);
3943 assert_eq!(app.config_profile.as_deref(), Some("new"));
3944 assert_eq!(app.configured_models, expected_models);
3945 assert_eq!(app.configured_models, config.custom_models.clone().unwrap());
3946 assert_eq!(app.model, "new-preview");
3947 assert_eq!(
3948 app.active_route_base_url,
3949 route.candidate.endpoint().base_url
3950 );
3951 assert_eq!(app.active_route_limits, Some(route.candidate.limits()));
3952 assert_eq!(
3953 app.active_context_window_source,
3954 route.context_window.source
3955 );
3956
3957 let mut empty = profile_fixture("no-metadata", "https://empty.example.test/v1");
3958 empty.custom_models = None;
3959 let empty_route = validated_profile_default_route(&empty).unwrap();
3960 apply_validated_profile_config(&mut app, &mut config, "empty", empty, &empty_route);
3961 assert!(app.configured_models.is_empty());
3962 assert!(config.custom_models.is_none());
3963 assert_eq!(app.config_profile.as_deref(), Some("empty"));
3964 assert_eq!(app.model, "no-metadata");
3965 assert_eq!(
3966 app.active_route_base_url,
3967 empty_route.candidate.endpoint().base_url
3968 );
3969 assert_eq!(
3970 app.active_context_window_source,
3971 empty_route.context_window.source
3972 );
3973 assert_ne!(
3974 app.active_context_window_source,
3975 crate::route_runtime::ContextWindowSource::UserDeclared,
3976 );
3977 })
3978 .unwrap()
3979 .join()
3980 .unwrap();
3981 }
3982 }
3983
3983 lines RUST