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