use super::*;
pub(crate) fn apply_agent_spawned_status_and_observer(
app: &mut App,
agent_id: &str,
prompt: &str,
prompt_summary: &str,
) {
let label = app.ensure_agent_label(agent_id);
codewhale_telemetry::session_counters().bump(codewhale_telemetry::Counter::SubagentSpawn);
app.status_message = Some(format!("{label} starting: {prompt_summary}"));
if let Err(error) =
execute_subagent_observer_hook(app, HookEvent::SubagentSpawn, agent_id, "prompt", prompt)
{
surface_observer_hook_submission_failure(app, error);
}
}
pub(crate) fn apply_agent_complete_status_and_observer(
app: &mut App,
agent_id: &str,
result: &str,
terminal_verb: &str,
) {
let label = app.agent_display_label(agent_id);
app.status_message = Some(format!(
"{label} {terminal_verb}: {}",
bound_agent_activity_text(result)
));
if let Err(error) =
execute_subagent_observer_hook(app, HookEvent::SubagentComplete, agent_id, "result", result)
{
surface_observer_hook_submission_failure(app, error);
}
}
pub(crate) fn apply_coordination_detail_projection(
app: &mut App,
projection: crate::tools::subagent::CoordinationDetailProjection,
) {
if !projection.process_lock_held {
let note = projection
.process_lock_note
.as_deref()
.unwrap_or("another Codewhale process owns delegated coordination for this workspace");
let same_process_handover =
note.contains(crate::tools::subagent::COORDINATION_SAME_PROCESS_HANDOVER);
let message = if note.contains(crate::tools::subagent::COORDINATION_LOCK_TIMEOUT_MARKER) {
"Timed out claiming delegated coordination for this workspace — job rows still settle locally.".to_string()
} else {
"Another CodeWhale session in this workspace owns delegated coordination — job rows still settle locally.".to_string()
};
let already = app
.status_toasts
.iter()
.any(|toast| toast.text.contains("delegated coordination"));
if !already && !same_process_handover {
app.push_status_toast(
message,
crate::tui::app::StatusToastLevel::Info,
Some(5_000),
);
}
}
app.coordination_detail = Some(projection);
}
pub(crate) fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
rail_panel_shortcut(app, crate::tui::work_surface::RailPanel::Pinned);
}
pub(crate) fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) {
if modifiers.contains(KeyModifiers::CONTROL) {
if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Top;
app.status_message = Some("Rail: top placement".to_string());
} else {
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
app.status_message = Some("Rail is off".to_string());
}
app.needs_redraw = true;
}
}
pub(crate) fn apply_picker_session_rename_to_active_app(
app: &mut App,
metadata: crate::session_manager::SessionMetadata,
) -> bool {
if app.current_session_id.as_deref() != Some(metadata.id.as_str()) {
return false;
}
app.session_title = Some(metadata.title.clone());
app.current_session_metadata = Some(metadata);
true
}
pub(crate) fn apply_engine_error_to_app(
app: &mut App,
envelope: crate::error_taxonomy::ErrorEnvelope,
) {
let recoverable = envelope.recoverable;
let message = envelope.message.clone();
let severity = envelope.severity;
let turn_was_in_progress =
app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"));
streaming_thinking::finalize_current(app);
if turn_was_in_progress {
app.finalize_streaming_assistant_as_interrupted();
app.finalize_active_cell_as_interrupted();
app.runtime_turn_status = Some("failed".to_string());
}
app.streaming_state.reset();
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;
if app
.hooks
.has_hooks_for_event(crate::hooks::HookEvent::OnError)
{
let context = app.base_hook_context().with_error(&message);
if let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context) {
surface_observer_hook_submission_failure(app, error);
}
}
app.add_message(HistoryCell::Error {
message: message.clone(),
severity,
});
app.is_loading = false;
app.dispatch_started_at = None;
app.turn_error_posted = true;
if matches!(
envelope.category,
crate::error_taxonomy::ErrorCategory::Authentication
) && app.api_key_env_only
{
app.offline_mode = true;
app.onboarding_needs_api_key = true;
app.onboarding = OnboardingState::Provider;
let provider = app.api_provider;
let config_path = match crate::config::resolve_load_config_path(app.config_path.clone()) {
Ok(Some(path)) => path.display().to_string(),
Ok(None) => "~/.codewhale/config.toml".to_string(),
Err(error) => error.to_string(),
};
app.status_message = Some(
tr(app.ui_locale, MessageId::OnboardApiKeyRejectedEnv)
.replace("{provider}", provider.as_str())
.replace("{env}", &provider.env_vars_label())
.replace("{path}", &config_path),
);
return;
}
if recoverable
&& matches!(
envelope.category,
crate::error_taxonomy::ErrorCategory::Network
| crate::error_taxonomy::ErrorCategory::RateLimit
| crate::error_taxonomy::ErrorCategory::Timeout
)
&& app.advance_fallback(message.clone()).is_some()
{
let position = app.fallback_chain_position().unwrap_or(0);
let total = app.fallback_chain_len();
app.status_message = Some(format!(
"Switched to {} (fallback {position}/{}) after recoverable provider error.",
app.api_provider.as_str(),
total.saturating_sub(1)
));
return;
}
if !recoverable {
app.offline_mode = true;
}
}
pub(crate) fn apply_message_submit_outcome(
app: &mut App,
message: &mut QueuedMessage,
outcome: crate::hooks::MessageSubmitOutcome,
) -> bool {
if let Some(warning) = outcome.warning() {
app.status_message = Some(warning.to_string());
}
match outcome {
crate::hooks::MessageSubmitOutcome::Unchanged { .. } => true,
crate::hooks::MessageSubmitOutcome::Replaced { text, .. } => {
message.display = text;
true
}
crate::hooks::MessageSubmitOutcome::Blocked { reason } => {
app.status_message = Some(reason);
false
}
}
}
pub(crate) fn apply_goal_snapshot_to_app(app: &mut App, snapshot: &GoalSnapshot) -> bool {
if snapshot.objective.is_none() && snapshot.status.trim() == "none" {
let changed = app.hunt.quarry.is_some()
|| app.hunt.token_budget.is_some()
|| app.hunt.tokens_used != 0
|| app.hunt.time_used_seconds != 0
|| app.hunt.continuation_count != 0
|| app.hunt.started_at.is_some()
|| app.hunt.finished_at.is_some()
|| app.hunt.verdict != HuntVerdict::default();
app.hunt = crate::tui::app::HuntState::default();
return changed;
}
let Some(objective) = snapshot
.objective
.as_deref()
.map(str::trim)
.filter(|objective| !objective.is_empty())
else {
tracing::warn!(
"ignoring objective-less runtime goal snapshot with non-clear status: {}",
snapshot.status
);
return false;
};
let Some(status) = goal_status_from_snapshot(snapshot) else {
tracing::warn!("ignoring unknown runtime goal status: {}", snapshot.status);
return false;
};
let verdict = HuntVerdict::from_goal_status(status);
let objective_changed = app.hunt.quarry.as_deref() != Some(objective);
let changed = objective_changed
|| app.hunt.token_budget != snapshot.token_budget
|| app.hunt.tokens_used != snapshot.tokens_used
|| app.hunt.time_used_seconds != snapshot.time_used_seconds
|| app.hunt.continuation_count != snapshot.continuation_count
|| app.hunt.pause_reason != snapshot.pause_reason
|| app.hunt.verdict != verdict;
if !changed {
return false;
}
app.hunt.quarry = Some(objective.to_string());
app.hunt.token_budget = snapshot.token_budget;
app.hunt.tokens_used = snapshot.tokens_used;
app.hunt.time_used_seconds = snapshot.time_used_seconds;
app.hunt.continuation_count = snapshot.continuation_count;
app.hunt.pause_reason = snapshot.pause_reason;
app.hunt.verdict = verdict;
if objective_changed || app.hunt.started_at.is_none() {
app.hunt.started_at = Some(Instant::now());
}
match verdict {
HuntVerdict::Hunted | HuntVerdict::Escaped | HuntVerdict::Wounded => {
if app.hunt.finished_at.is_none() {
app.hunt.finished_at = Some(Instant::now());
}
}
HuntVerdict::Hunting => app.hunt.finished_at = None,
}
true
}
pub(crate) async fn apply_mode_update(
app: &mut App,
engine_handle: &EngineHandle,
mode: AppMode,
) -> bool {
let outcome = app.select_mode(mode);
app.report_mode_selection(mode, outcome);
if outcome.changed_live_state() {
sync_mode_update(app, engine_handle).await;
true
} else {
false
}
}
pub(crate) async fn apply_model_and_compaction_update(
engine_handle: &EngineHandle,
compaction: crate::compaction::CompactionConfig,
mode: AppMode,
route_limits: Option<codewhale_config::route::RouteLimits>,
) {
let _ = engine_handle
.send(Op::SetModel {
model: compaction.model.clone(),
mode,
route_limits,
})
.await;
let _ = engine_handle
.send(Op::SetCompaction { config: compaction })
.await;
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_model_picker_choice(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
model: String,
target_provider: Option<ApiProvider>,
target_provider_id: Option<String>,
effort: crate::tui::app::ReasoningEffort,
previous_model: String,
previous_effort: crate::tui::app::ReasoningEffort,
save_as_startup_default: bool,
) {
if app.reject_setting_change_while_busy(
crate::localization::MessageId::SettingSubjectModelAndThinking,
) {
note_startup_default_not_saved(app, save_as_startup_default);
return;
}
let target_provider = target_provider.unwrap_or(app.api_provider);
let target_identity = if target_provider == ApiProvider::Custom {
target_provider_id.unwrap_or_else(|| config.provider_identity_for(target_provider))
} else {
target_provider.as_str().to_string()
};
let model_is_auto = model.trim().eq_ignore_ascii_case("auto");
let preserve_auto_effort =
app.reasoning_effort_preference.is_some() || effort != previous_effort;
if target_provider != app.api_provider
|| target_identity != app.provider_identity_for_persistence()
{
config.provider = Some(target_identity.clone());
switch_provider(
app,
engine_handle,
config,
target_provider,
(!model_is_auto).then_some(model.clone()),
)
.await;
if app.api_provider != target_provider
|| app.provider_identity_for_persistence() != target_identity
{
note_startup_default_not_saved(app, save_as_startup_default);
return;
}
if !model_is_auto {
apply_picker_effort_choice(app, engine_handle, effort, previous_effort).await;
if save_as_startup_default {
app.status_message = Some(app.save_live_route_as_startup_default());
}
return;
}
}
let model_changed = model != previous_model || app.auto_model != model_is_auto;
let mut resolved_model = model.clone();
let mut route_base_url = config.deepseek_base_url();
if !model_is_auto {
let saved_provider_model = config
.provider_config_for(app.api_provider)
.and_then(|provider| provider.model.as_deref());
match crate::route_runtime::resolve_route_candidate_with_context_metadata(
app.api_provider,
Some(&model),
saved_provider_model,
Some(config.deepseek_base_url()),
config.context_window_for_provider_config(app.api_provider),
None,
) {
Ok(resolution) => {
resolved_model = resolution.candidate.wire_model_id().as_str().to_string();
route_base_url = resolution.candidate.endpoint().base_url.clone();
if model_changed {
app.set_active_context_window_override(
config.context_window_for_provider_config(app.api_provider),
);
app.set_active_route_resolution(
route_base_url.clone(),
resolution.candidate.limits(),
resolution.context_window.source,
);
}
}
Err(reason) => {
app.status_message = Some(reason);
note_startup_default_not_saved(app, save_as_startup_default);
return;
}
}
} else if model_changed {
app.set_active_context_window_override(
config.context_window_for_provider_config(app.api_provider),
);
app.active_route_limits = app.context_window_override_limits();
app.active_route_base_url = route_base_url.clone();
app.active_context_window_source = if app.active_context_window_override.is_some() {
crate::route_runtime::ContextWindowSource::Configured
} else {
crate::route_runtime::ContextWindowSource::Fallback
};
}
let effective_effort = if model_is_auto {
effort
} else {
effort.normalize_for_route(app.api_provider, &route_base_url, &resolved_model)
};
let effort_changed = effort != previous_effort;
if model_changed {
app.set_model_selection(resolved_model.clone());
let provider_identity = app.provider_identity_for_persistence().to_string();
app.provider_models
.insert(provider_identity.clone(), resolved_model.clone());
app.enable_provider_model(&provider_identity, &resolved_model);
app.clear_model_scoped_telemetry();
}
let preference_changed = if model_is_auto && !preserve_auto_effort {
app.reasoning_effort_preference.take().is_some()
} else {
let changed = app.reasoning_effort_preference != Some(effort);
app.reasoning_effort_preference = Some(effort);
changed
};
let live_effort_changed = effective_effort != app.reasoning_effort;
if !model_is_auto || preserve_auto_effort {
app.reasoning_effort = effective_effort;
} else {
app.reasoning_effort = ReasoningEffort::Auto;
}
if live_effort_changed || preference_changed {
app.invalidate_route_receipts_for_reasoning_change();
}
if model_changed || live_effort_changed || preference_changed {
app.update_model_compaction_budget();
}
let route_provider = app.provider_identity_for_persistence().to_string();
app.note_session_route_change(&route_provider, &resolved_model);
if model_changed {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
app.mode,
app.active_route_limits,
)
.await;
}
let model_summary = if model_is_auto {
"auto (per-turn model)".to_string()
} else {
resolved_model.clone()
};
let previous_effort_summary = previous_effort.display_label_for_provider(app.api_provider);
let applied_effort = app.reasoning_effort;
let effort_summary = if applied_effort == ReasoningEffort::Auto {
"auto (per-turn thinking)".to_string()
} else {
applied_effort
.display_label_for_provider(app.api_provider)
.to_string()
};
let summary = match (model_changed, effort_changed) {
(true, true) => format!(
"Model: {previous_model} → {model_summary} · thinking: {previous_effort_summary} → {effort_summary}"
),
(true, false) => {
format!("Model: {previous_model} → {model_summary} · thinking {effort_summary}")
}
(false, true) => format!(
"Thinking: {previous_effort_summary} → {effort_summary} · model {model_summary}"
),
(false, false) => {
format!("Model unchanged: {model_summary} · thinking {effort_summary}")
}
};
app.status_message = Some(summary);
if model_changed || !model_is_auto {
record_provider_model_setup_progress(app, config);
}
if save_as_startup_default {
app.status_message = Some(app.save_live_route_as_startup_default());
}
}
pub(crate) async fn apply_picker_effort_choice(
app: &mut App,
engine_handle: &EngineHandle,
effort: ReasoningEffort,
previous_effort: ReasoningEffort,
) {
if app.reject_setting_change_while_busy(crate::localization::MessageId::SettingSubjectThinking)
{
return;
}
let effective_effort = if app.auto_model {
effort
} else {
effort.normalize_for_route(app.api_provider, &app.active_route_base_url, &app.model)
};
let live_changed = effective_effort != app.reasoning_effort;
let preference_changed = app.reasoning_effort_preference != Some(effort);
let selection_changed = effort != previous_effort || live_changed;
if live_changed || preference_changed {
app.reasoning_effort = effective_effort;
app.reasoning_effort_preference = Some(effort);
}
if selection_changed {
app.invalidate_route_receipts_for_reasoning_change();
app.update_model_compaction_budget();
}
let persist_warning = app
.startup_defaults
.apply_blocking(
crate::tui::startup_defaults::StartupDefaults::reasoning_effort(effort.as_setting()),
)
.err()
.map(|err| format!(" (not persisted: {err})"));
if live_changed {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
app.mode,
app.active_route_limits,
)
.await;
}
let persisted = persist_warning.is_none();
let mut summary = if selection_changed {
format!(
"Thinking: {} → {} · model {}",
previous_effort.display_label_for_provider(app.api_provider),
effort.display_label_for_provider(app.api_provider),
app.model_display_label()
)
} else {
let mut summary = format!(
"Thinking unchanged: {} · model {}",
effort.display_label_for_provider(app.api_provider),
app.model_display_label()
);
if persisted {
summary.push_str(" · ");
summary.push_str(&app.tr(crate::localization::MessageId::SavedAsStartupDefault));
}
summary
};
if let Some(warning) = persist_warning {
summary.push_str(&warning);
}
app.status_message = Some(summary);
}
pub(crate) async fn apply_provider_fallback_switch(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
rollback: ProviderFallbackRollback,
) {
let ProviderFallbackRollback {
identity: previous_identity,
chain: previous_chain,
} = rollback;
let previous_provider = previous_identity.provider;
let target = app.api_provider;
let previous_model = app.model.clone();
let resolved_route = match resolve_runtime_route(config, target, None) {
Ok(route) => route,
Err(reason) => {
app.set_provider_identity_record(previous_identity.clone());
app.provider_chain = previous_chain.clone();
app.last_fallback_reason = Some(format!(
"Fallback provider {} route was rejected: {reason}",
target.as_str()
));
app.status_message = Some(format!(
"Fallback provider {} rejected; provider remains {}.",
target.as_str(),
previous_provider.as_str()
));
return;
}
};
let target_identity = resolved_route.identity.clone();
let resolved_endpoint = resolved_route.candidate.endpoint().base_url.clone();
let next_config = resolved_route.config;
let new_model = resolved_route.model;
let context_window_source = resolved_route.context_window.source;
if let Err(err) = DeepSeekClient::from_candidate(&next_config, &resolved_route.candidate) {
app.set_provider_identity_record(previous_identity);
app.provider_chain = previous_chain;
app.last_fallback_reason = Some(format!(
"Fallback provider {} was unavailable: {err}",
target.as_str()
));
app.status_message = Some(format!(
"Fallback provider {} unavailable; provider remains {}.",
target.as_str(),
previous_provider.as_str()
));
return;
}
*config = *next_config;
app.set_provider_identity_record(target_identity);
app.billing_presentation = crate::route_billing::for_route(config, target);
let new_base_url = resolved_endpoint;
let new_endpoint = display_base_url_host(&new_base_url);
let cache_scope_changed = previous_provider != target || previous_model != new_model;
app.model_ids_passthrough = config.model_ids_pass_through();
app.set_model_selection(new_model.clone());
app.apply_provider_switch_reasoning_effort(target, &new_base_url, None);
app.set_active_context_window_override(config.context_window_for_provider_config(target));
app.set_active_route_resolution(
new_base_url.clone(),
resolved_route.candidate.limits(),
context_window_source,
);
app.update_model_compaction_budget();
if cache_scope_changed {
app.clear_model_scoped_telemetry();
} else {
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
}
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
system_prompt_override: false,
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
})
.await;
}
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
app.add_message(HistoryCell::System {
content: format!(
"Provider fallback: {} -> {}\nModel: {} -> {}\nEndpoint: {}",
previous_provider.as_str(),
target.as_str(),
previous_model,
new_model,
new_endpoint
),
});
app.status_message = Some(format!(
"Fallback provider: {} via {}",
target.as_str(),
new_endpoint
));
}
pub(crate) async fn apply_command_result(
terminal: &mut AppTerminal,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
#[cfg_attr(not(feature = "web"), allow(unused_variables))] web_config_session: &mut Option<
WebConfigSession,
>,
result: commands::CommandResult,
) -> Result<bool> {
if let Some(msg) = result.message {
app.add_message(HistoryCell::System { content: msg });
}
if let Some(action) = result.action {
match action {
AppAction::Quit => {
let _ = engine_handle.send(Op::Shutdown).await;
return Ok(true);
}
AppAction::LoadSession(path) => {
let session: SavedSession = match std::fs::read_to_string(&path)
.map_err(|err| err.to_string())
.and_then(|raw| serde_json::from_str(&raw).map_err(|err| err.to_string()))
{
Ok(session) => session,
Err(err) => {
app.status_message = Some(format!(
"Failed to load session from {}: {err}",
path.display()
));
return Ok(false);
}
};
let fresh_config =
match Config::load(app.config_path.clone(), app.config_profile.as_deref()) {
Ok(config) => config,
Err(err) => {
app.status_message = Some(format!(
"Failed to load live config for session restore: {err}"
));
return Ok(false);
}
};
let respawn = match apply_loaded_session_config_snapshot(
app,
config,
&session,
fresh_config,
true,
) {
Ok(outcome) => outcome,
Err(err) => {
app.status_message = Some(format!("Failed to restore session: {err}"));
return Ok(false);
}
};
sync_runtime_workspace_state(task_manager, app.workspace.clone()).await;
if respawn {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
} else {
let _ = engine_handle
.send(Op::SetModel {
model: app.model.clone(),
mode: app.mode,
route_limits: app.active_route_limits,
})
.await;
}
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
system_prompt_override: false,
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
})
.await;
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
let success_message = format!(
"Session loaded from {} (ID: {}, {} messages)",
path.display(),
crate::session_manager::truncate_id(&session.metadata.id),
session.metadata.message_count
);
app.add_message(HistoryCell::System {
content: success_message.clone(),
});
app.status_message = Some(success_message);
}
AppAction::SyncSession {
session_id,
messages,
system_prompt,
model,
workspace,
mode,
} => {
let mut session_id = session_id;
let is_full_reset = messages.is_empty() && system_prompt.is_none();
if is_full_reset && session_id.is_none() {
let new_session_id = uuid::Uuid::new_v4().to_string();
app.current_session_id = Some(new_session_id.clone());
session_id = Some(new_session_id);
}
let workspace_changed = task_manager.default_workspace().await != workspace;
if workspace_changed {
apply_workspace_runtime_state(app, config, workspace.clone());
sync_runtime_workspace_state(task_manager, workspace.clone()).await;
}
let provider_changed = config.api_provider() != app.api_provider
|| config.provider_identity_for(config.api_provider())
!= app.provider_identity_for_persistence();
if provider_changed {
let identity = match config
.resolve_provider_identity(app.provider_identity_for_persistence())
{
Ok(identity) => identity,
Err(err) => {
app.status_message =
Some(format!("Failed to restore saved session provider: {err}"));
return Ok(false);
}
};
restore_loaded_session_provider(app, config, identity);
config.set_provider_model_override(app.api_provider, Some(model.clone()));
}
resolve_loaded_session_route(app, config);
app.update_model_compaction_budget();
if provider_changed || workspace_changed {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
}
let _ = engine_handle
.send(Op::SetModel {
model: model.clone(),
mode,
route_limits: app.active_route_limits,
})
.await;
let _ = engine_handle
.send(Op::SyncSession {
session_id,
messages,
system_prompt,
system_prompt_override: false,
model,
workspace,
mode,
})
.await;
let _ = engine_handle
.send(Op::SetCompaction {
config: app.compaction_config(),
})
.await;
if is_full_reset {
persist_full_reset_snapshot(app);
}
}
AppAction::ModeChanged(_mode) => {
sync_mode_update(app, engine_handle).await;
}
AppAction::ApprovalPolicyPersisted { policy } => {
config.approval_policy = policy;
sync_mode_update(app, engine_handle).await;
}
AppAction::PermissionRulesChanged => {
match codewhale_config::load_permissions_snapshot(app.config_path.clone()) {
Ok(snapshot) => {
let ruleset = snapshot.permissions().ruleset();
config.exec_policy_engine.set_ruleset(ruleset.clone());
if let Err(error) = engine_handle
.send(Op::SetPermissionRuleset { ruleset })
.await
{
app.status_message = Some(
tr(app.ui_locale, MessageId::PermissionsOperationFailed)
.replace("{error}", &error.to_string()),
);
}
}
Err(error) => {
app.status_message = Some(
tr(app.ui_locale, MessageId::PermissionsOperationFailed)
.replace("{error}", &format!("{error:#}")),
);
}
}
}
AppAction::PluginRegistryChanged => {
let _ = engine_handle.send(Op::Shutdown).await;
*engine_handle = spawn_tui_engine(build_engine_config(app, config), config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
system_prompt_override: false,
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
})
.await;
}
}
AppAction::SendMessage(content) => {
let queued = build_queued_message(app, content);
dispatch_composer_message(
app,
config,
engine_handle,
queued,
DispatchRecovery::Immediate,
ComposerSubmitAction::Submit(app.decide_submit_disposition()),
)
.await?;
}
AppAction::SetGoalStatus { status, clear } => {
let _ = engine_handle
.send(Op::SetGoalStatus { status, clear })
.await;
}
AppAction::OpenTextPager { title, content } => {
open_text_pager(app, title, content);
}
AppAction::VoiceCapture => {
use commands::voice::VoiceCaptureOutcome;
match commands::voice::capture_and_transcribe(app, config).await {
Ok(VoiceCaptureOutcome::Insert(text)) => {
app.insert_str(&text);
app.status_message = Some(format!(
"{}: {text}",
tr(app.ui_locale, MessageId::VoiceTranscribed)
));
}
Ok(VoiceCaptureOutcome::Send(content)) => {
app.status_message =
Some(tr(app.ui_locale, MessageId::VoiceTranscribed).to_string());
let queued = build_queued_message(app, content);
dispatch_composer_message(
app,
config,
engine_handle,
queued,
DispatchRecovery::Immediate,
ComposerSubmitAction::Submit(app.decide_submit_disposition()),
)
.await?;
}
Err(err) => {
app.voice_enabled = false;
app.status_message = Some(err);
}
}
}
AppAction::ListSubAgents => {
let _ = engine_handle.try_send(Op::ListSubAgents);
}
AppAction::PreviewOutboundRequest {
json,
base_prompt_only,
hypothetical_prompt,
} => {
let inputs =
build_preview_request_inputs(app, config, engine_handle, hypothetical_prompt)
.await;
if let Err(err) = engine_handle
.send(Op::PreviewOutboundRequest {
inputs: Box::new(inputs),
json,
base_prompt_only,
})
.await
{
app.status_message = Some(format!("Cannot preview request: {err}"));
}
}
AppAction::CancelSubAgent { agent_id } => {
app.status_message = Some(format!("Cancelling {agent_id}..."));
if engine_handle
.send(Op::CancelSubAgent {
agent_id: agent_id.clone(),
})
.await
.is_err()
{
app.status_message = Some(format!("Could not cancel {agent_id}"));
}
}
AppAction::FetchModels => {
app.status_message = Some("Fetching models...".to_string());
match fetch_available_models(config).await {
Ok(models) => {
app.add_message(HistoryCell::System {
content: format_helpers::available_models_message(&app.model, &models),
});
app.status_message = Some(format!("Found {} model(s)", models.len()));
}
Err(error) => {
app.add_message(HistoryCell::System {
content: format!(
"Failed to fetch models from {}: {error}",
config.api_provider().display_name()
),
});
}
}
}
AppAction::RefreshModelsDevCatalog => {
app.status_message = Some("Refreshing Models.dev catalog...".to_string());
let message = match crate::models_dev_live::refresh(true).await {
Ok(count) => {
let status = crate::models_dev_live::status();
let source = if status.source_label.is_empty() {
"unknown"
} else {
status.source_label.as_str()
};
format!(
"Models.dev catalog refreshed: {count} offerings ({:?}, source {source})",
status.freshness
)
}
Err(err) => {
let status = crate::models_dev_live::status();
format!(
"Models.dev refresh failed ({err}); keeping prior/bundled rows ({} offerings, {:?})",
status.offering_count, status.freshness
)
}
};
app.add_message(HistoryCell::System {
content: message.clone(),
});
app.status_message = Some(message);
}
AppAction::CacheWarmup => {
app.status_message = Some("Warming prompt cache...".to_string());
match run_cache_warmup(app, config).await {
Ok(outcome) => {
app.session.last_base_url = Some(outcome.base_url.clone());
app.session.last_warmup_key = Some(CacheWarmupKey::from_inspection(
&outcome.provider_identity,
&outcome.model,
&outcome.base_url,
&outcome.inspection,
));
let mut message = format_helpers::cache_warmup_result(&outcome.usage);
if let Some(key) = app.session.last_warmup_key.as_ref() {
message.push_str(&format!("\nWarmup key: {}", key.hash_short()));
}
if app.prefix_checks_total > 0 {
let changes = app.prefix_change_count;
let total = app.prefix_checks_total;
let stable = total.saturating_sub(changes);
let pct = app
.prefix_stability_pct
.map(|p| format!("{p}%"))
.unwrap_or_else(|| "--".to_string());
message.push_str(&format!(
"\n\nPrefix stability: {pct} ({stable}/{total} checks stable, {changes} change{})",
if changes == 1 { "" } else { "s" }
));
if let Some(ref desc) = app.last_prefix_change_desc {
message.push_str(&format!("\nLast prefix change: {desc}"));
}
}
app.add_message(HistoryCell::System { content: message });
app.status_message = Some("Cache warmup complete".to_string());
}
Err(error) => {
app.add_message(HistoryCell::System {
content: format!("Cache warmup failed: {error}"),
});
app.status_message = Some("Cache warmup failed".to_string());
}
}
}
AppAction::SwitchProvider { provider, model } => {
switch_provider(app, engine_handle, config, provider, model).await;
let balance_cooldown_expired = app
.last_balance_fetch
.is_none_or(|t| t.elapsed() >= BALANCE_FETCH_COOLDOWN);
if balance_cooldown_expired && should_fetch_deepseek_balance(app) {
let cell = app.balance_cell.clone();
let api_key = config.deepseek_api_key().unwrap_or_default();
let base_url = config.deepseek_base_url();
if !api_key.is_empty() {
app.last_balance_fetch = Some(Instant::now());
tokio::spawn(async move {
if let Some(info) = fetch_deepseek_balance(&api_key, &base_url).await
&& let Ok(mut guard) = cell.lock()
{
*guard = Some(info);
}
});
}
} else {
if let Ok(mut guard) = app.balance_cell.lock() {
*guard = None;
}
}
}
AppAction::SwitchModelRoute { provider, model } => {
let previous_model = if app.auto_model {
"auto".to_string()
} else {
app.model.clone()
};
let previous_effort = app
.reasoning_effort_preference
.unwrap_or(app.reasoning_effort);
apply_model_picker_choice(
app,
engine_handle,
config,
model,
Some(provider),
None,
previous_effort,
previous_model,
previous_effort,
false,
)
.await;
}
AppAction::UpdateCompaction(compaction) => {
if app.is_loading || app.is_compacting {
let queued = try_apply_model_and_compaction_update(
engine_handle,
compaction,
app.mode,
app.active_route_limits,
);
app.status_message = Some(if queued {
"Config change queued; the active turn remains responsive.".to_string()
} else {
"Config change deferred; it will apply to the next turn.".to_string()
});
} else {
apply_model_and_compaction_update(
engine_handle,
compaction,
app.mode,
app.active_route_limits,
)
.await;
}
}
AppAction::UpdateStreamChunkTimeout(timeout_secs) => {
let _ = engine_handle
.send(Op::SetStreamChunkTimeout { timeout_secs })
.await;
}
AppAction::UpdateSubagentRuntimeConfig {
enabled,
max_subagents,
launch_concurrency,
max_spawn_depth,
api_timeout_secs,
heartbeat_timeout_secs,
} => {
let _ = engine_handle
.send(Op::SetSubagentRuntimeConfig {
enabled,
max_subagents,
launch_concurrency,
max_spawn_depth,
api_timeout_secs,
heartbeat_timeout_secs,
})
.await;
}
AppAction::SetAdvisorEnabled { enabled } => {
let _ = engine_handle.send(Op::SetAdvisorEnabled { enabled }).await;
}
AppAction::OpenConfigEditor(mode) => match mode {
ConfigUiMode::Native => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
app.view_stack.push(ConfigView::new_for_app(app));
}
}
ConfigUiMode::Tui => {
pause_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
let editor_result = config_ui::run_tui_editor(app, config)
.and_then(|doc| config_ui::apply_document(doc, app, config, true));
resume_terminal(
terminal,
app.use_alt_screen,
app.use_mouse_capture,
app.use_bracketed_paste,
app.synchronized_output_enabled,
)?;
match editor_result {
Ok(outcome) => {
if outcome.requires_engine_sync {
apply_model_and_compaction_update(
engine_handle,
app.compaction_config(),
app.mode,
app.active_route_limits,
)
.await;
}
app.add_message(HistoryCell::System {
content: outcome.final_message.clone(),
});
app.status_message = Some(outcome.final_message);
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Config UI failed: {err}"),
});
}
}
}
ConfigUiMode::Web => {
#[cfg(feature = "web")]
{
let session = config_ui::start_web_editor(app, config).await?;
let url = format!("http://{}", session.addr);
let open_err = config_ui::open_browser(&url).err();
if let Some(err) = open_err {
app.add_message(HistoryCell::System {
content: format!("Failed to open browser automatically: {err}"),
});
}
app.status_message = Some(format!("web ui listen on: {url}"));
*web_config_session = Some(session);
}
#[cfg(not(feature = "web"))]
{
app.add_message(HistoryCell::System {
content: "This build does not include the web config UI.".to_string(),
});
}
}
},
AppAction::OpenConfigView => {
if app.view_stack.top_kind() != Some(ModalKind::Config) {
app.view_stack.push(ConfigView::new_for_app(app));
}
}
AppAction::OpenWorktreeManager => {
if app.view_stack.top_kind() != Some(ModalKind::WorktreeManager) {
crate::tui::git_status::refresh_if_stale(&app.workspace);
app.view_stack
.push(crate::tui::worktree_manager::WorktreeManagerView::new(
app.workspace.clone(),
));
}
}
AppAction::OpenModelPicker => {
if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
app.view_stack
.push(crate::tui::model_picker::ModelPickerView::new(app, config));
}
}
AppAction::OpenProviderPicker => {
if app.onboarding == OnboardingState::Provider {
open_onboarding_provider_picker(app, config, engine_handle, true).await;
} else if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
let runtime_status = query_provider_runtime_status(engine_handle).await;
app.view_stack.push(
crate::tui::provider_picker::ProviderPickerView::new_with_runtime_status_and_memory(
app.api_provider,
config,
runtime_status,
app.provider_picker_memory.as_ref(),
)
.with_locale(app.ui_locale)
.with_provider_health(&app.provider_health),
);
}
}
AppAction::OpenProviderSetup { provider } => {
if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) {
let runtime_status = query_provider_runtime_status(engine_handle).await;
app.view_stack.push(
crate::tui::provider_picker::ProviderPickerView::new_for_setup(
app.api_provider,
provider,
config,
runtime_status,
)
.with_locale(app.ui_locale)
.with_provider_health(&app.provider_health),
);
app.status_message = Some("Provider setup catalog opened.".to_string());
}
}
AppAction::StartXaiDeviceLogin => {
let _switched =
run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
}
AppAction::OpenModePicker => {
if app.view_stack.top_kind() != Some(ModalKind::ModePicker) {
app.view_stack
.push(crate::tui::views::mode_picker::ModePickerView::new(
app.mode,
app.ui_locale,
));
}
}
AppAction::OpenStatusPicker => {
if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) {
app.view_stack
.push(crate::tui::views::status_picker::StatusPickerView::new(
&app.status_items,
app.api_provider,
app.ui_locale,
));
}
}
AppAction::OpenFeedbackPicker => {
if app.view_stack.top_kind() != Some(ModalKind::FeedbackPicker) {
app.view_stack
.push(crate::tui::feedback_picker::FeedbackPickerView::new());
}
}
AppAction::OpenThemePicker => {
if app.view_stack.top_kind() != Some(ModalKind::ThemePicker) {
let original = app.theme_id.name().to_string();
app.view_stack.push_boxed(
crate::tui::theme_picker::ThemePickerView::boxed_with_treatment(
original,
app.ocean_treatment,
app.ui_locale,
app.background_color_override,
),
);
}
}
AppAction::OpenSkillsManager => {
if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
app.view_stack
.push(crate::tui::views::skills_manager::SkillsManagerView::new(
app,
));
}
}
AppAction::OpenFleetList => {
if app.view_stack.top_kind() != Some(ModalKind::FleetList) {
app.view_stack
.push(crate::tui::views::fleet_list::FleetListView::new(
app, config,
));
}
}
AppAction::OpenFleetRoster => {
if app.view_stack.top_kind() != Some(ModalKind::FleetRoster) {
app.view_stack
.push(crate::tui::views::fleet_roster::FleetRosterView::new(
app, config,
));
}
}
AppAction::OpenFleetSetup => {
if app.view_stack.top_kind() != Some(ModalKind::FleetSetup) {
let _ = app.next_draft_gen();
app.view_stack
.push(crate::tui::views::fleet_setup::FleetSetupView::new(
app, config,
));
}
}
AppAction::OpenHotbarSetup => {
if app.view_stack.top_kind() != Some(ModalKind::HotbarSetup) {
app.view_stack
.push(crate::tui::hotbar::setup::HotbarSetupView::new(app, config));
}
}
AppAction::OpenSetupWizard => {
if app.view_stack.top_kind() != Some(ModalKind::SetupWizard) {
let _ = app.next_draft_gen();
app.view_stack
.push(crate::tui::setup::SetupWizardView::new_for_app(app, config));
}
}
AppAction::OpenSetupWizardAt { step } => {
if app.view_stack.top_kind() != Some(ModalKind::SetupWizard) {
let _ = app.next_draft_gen();
app.view_stack
.push(crate::tui::setup::SetupWizardView::new_for_app_at(
app, config, step,
));
}
}
AppAction::UseBundledConstitution => use_bundled_constitution(app, config),
AppAction::PreviewEffectiveBasePrompt => preview_effective_base_prompt(app, config),
AppAction::DisableHotbar => disable_hotbar(app, config),
AppAction::RestoreHotbarDefaults => restore_hotbar_defaults(app, config),
AppAction::OpenExternalUrl { url, label } => match open_external_url(&url) {
Ok(()) => {
app.status_message = Some(format!("Opened {label} in your browser"));
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!(
"Could not open {label} automatically: {err}\n\nThe URL is printed above."
),
});
}
},
AppAction::OpenContextInspector => {
open_context_inspector(app);
}
AppAction::OpenLiveTranscript => {
open_live_transcript_overlay(app);
}
AppAction::OpenTurnInspector => {
open_turn_inspector_pager(app);
}
AppAction::CompactContext { focus } => {
try_queue_manual_compaction(app, config, engine_handle, focus);
}
AppAction::PurgeContext => {
app.status_message = Some("Agent purging context...".to_string());
let _ = engine_handle.send(Op::PurgeContext).await;
}
AppAction::TaskAdd { prompt } => {
let request = NewTaskRequest {
prompt: prompt.clone(),
model: Some(app.model.clone()),
workspace: Some(app.workspace.clone()),
mode: Some(task_mode_label(app.mode).to_string()),
allow_shell: Some(app.allow_shell),
trust_mode: Some(app.trust_mode),
auto_approve: Some(app_auto_approve_enabled(app)),
owner_session_id: app.current_session_id.clone(),
};
match task_manager.add_task(request).await {
Ok(task) => {
app.add_message(HistoryCell::System {
content: format!(
"Task queued: {} ({})",
task.id,
summarize_tool_output(&task.prompt)
),
});
app.status_message = Some(format!("Queued {}", task.id));
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Failed to queue task: {err}"),
});
}
}
refresh_active_task_panel(app, task_manager).await;
}
AppAction::TaskList => {
let tasks = task_manager.list_tasks(Some(30)).await;
refresh_active_task_panel(app, task_manager).await;
app.add_message(HistoryCell::System {
content: format_task_list(&tasks),
});
}
AppAction::RemoteControl(action) => match action {
crate::remote_control::RemoteControlAction::Start => {
start_remote_control_session(app);
}
crate::remote_control::RemoteControlAction::Stop => {
app.remote_control.stop();
let status = app.remote_control.status_line();
if app.remote_control.blocks_local_input() {
app.sticky_status = Some(StatusToast::new(
status.clone(),
StatusToastLevel::Warning,
None,
));
} else {
app.sticky_status = None;
}
app.status_message = Some(status);
}
},
AppAction::TaskShow { id } => match task_manager.get_task(&id).await {
Ok(task) => open_task_pager(app, &task),
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Task lookup failed: {err}"),
});
}
},
AppAction::TaskCancel { id } => {
match task_manager.cancel_task(&id).await {
Ok(cancellation) => {
app.add_message(HistoryCell::System {
content: format!(
"Task {} status: {:?}",
cancellation.task.id, cancellation.task.status
),
});
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Task cancel failed: {err}"),
});
}
}
refresh_active_task_panel(app, task_manager).await;
}
AppAction::Automation(action) => {
crate::tui::automation_routing::handle_action(app, action, task_manager).await;
}
AppAction::ShellJob(action) => {
handle_shell_job_action(app, action);
refresh_active_task_panel(app, task_manager).await;
}
AppAction::Mcp(action) => {
handle_mcp_ui_action(app, engine_handle, config, action).await;
}
AppAction::SwitchWorkspace { workspace } => {
switch_workspace(app, engine_handle, task_manager, config, workspace).await;
}
AppAction::SwitchProfile { profile } => {
let previous_profile = app.config_profile.clone();
match Config::load(app.config_path.clone(), Some(&profile)).and_then(|new_config| {
validated_profile_default_route(&new_config)
.map(|validated_route| (new_config, validated_route))
}) {
Ok((new_config, validated_route)) => {
let new_model = validated_route.model.clone();
let provider_identity = validated_route.identity.clone();
let route_limits = crate::route_budget::known_route_limits(
validated_route.candidate.limits(),
);
app.config_profile = Some(profile.clone());
*config = new_config.clone();
app.set_provider_identity_record(provider_identity);
app.billing_presentation =
crate::route_billing::for_route(config, app.api_provider);
app.set_model_selection(new_model.clone());
app.set_active_context_window_override(
config.context_window_for_provider_config(app.api_provider),
);
app.active_route_limits = route_limits;
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
let _ = engine_handle.send(Op::Shutdown).await;
let engine_config = build_engine_config(app, config);
*engine_handle = spawn_tui_engine(engine_config, config);
if !app.api_messages.is_empty() {
let _ = engine_handle
.send(Op::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
system_prompt_override: false,
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
})
.await;
}
app.add_message(HistoryCell::System {
content: format!(
"Switched to profile '{profile}'. Model: {new_model}, Provider: {}",
app.provider_identity_for_persistence()
),
});
app.status_message = Some(format!("Profile: {profile}"));
}
Err(err) => {
app.config_profile = previous_profile;
app.status_message =
Some(format!("Failed to switch to profile '{profile}': {err}"));
}
}
}
AppAction::ShareSession {
history_len: _,
model,
mode,
} => {
let status = if app.api_messages.is_empty() {
"No session content to share.".to_string()
} else {
let history_json = serde_json::to_string_pretty(&app.api_messages)
.unwrap_or_else(|_| "[]".to_string());
match crate::commands::share::perform_share(&history_json, &model, &mode).await
{
Ok(url) => format!("Session shared! URL: {url}"),
Err(err) => format!("Share failed: {err}"),
}
};
app.add_message(HistoryCell::System {
content: status.clone(),
});
app.status_message = Some(status);
}
}
}
Ok(false)
}
pub(crate) fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) {
app.workspace = workspace.clone();
app.coordination_detail = None;
app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
app.active_skill = None;
app.active_skill_provenance = None;
app.hooks = app.hooks.rebind(
crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace),
workspace.clone(),
);
app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config);
app.skills_scan_codewhale_only = config.skills_config().scan_codewhale_only();
app.project_context_pack_enabled = config.project_context_pack_enabled();
app.refresh_skill_cache();
app.workspace_context = None;
if let Ok(mut cell) = app.workspace_context_cell.lock() {
*cell = None;
}
app.workspace_context_refreshed_at = None;
app.file_tree = None;
let shell_manager = crate::tools::shell::new_shared_shell_manager(workspace);
app.runtime_services.shell_manager = Some(shell_manager);
app.runtime_services.hook_executor = Some(std::sync::Arc::new(app.hooks.clone()));
}
pub(crate) fn apply_hotbar_setup_saved(
app: &mut App,
config: &mut Config,
bindings: Vec<codewhale_config::HotbarBindingToml>,
) {
match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &bindings)
{
Ok(path) => {
config.hotbar = Some(bindings);
app.status_message = Some(format!("Hotbar bindings saved to {}", path.display()));
}
Err(err) => {
app.status_message = Some(format!("Failed to save Hotbar bindings: {err}"));
app.add_message(HistoryCell::System {
content: format!("Failed to save Hotbar bindings: {err}"),
});
}
}
app.needs_redraw = true;
}
pub(crate) async fn apply_approval_decision(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
event: ApprovalDecisionEvent,
) {
if event.decision == ReviewDecision::ApprovedForSession {
app.approval_session_approved
.insert(event.tool_name.clone());
app.approval_session_approved
.insert(event.approval_grouping_key.clone());
}
if matches!(
event.decision,
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
) && !event.persistent_rules.is_empty()
&& !event.timed_out
{
persist_rules_from_approval(app, config, &event.persistent_rules);
}
match event.decision {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
let _ = engine_handle.approve_tool_call(event.tool_id).await;
}
ReviewDecision::Denied => {
if !event.timed_out {
app.approval_session_denied.insert(event.approval_key);
}
let _ = engine_handle.deny_tool_call(event.tool_id).await;
}
ReviewDecision::Abort => {
engine_handle.cancel();
mark_active_turn_cancelled_locally(app);
app.status_message = Some(parent_stop_status(app, "Request cancelled"));
}
}
}
pub(crate) fn apply_setup_runtime_preset(
app: &mut App,
config: &mut Config,
preset: crate::tui::setup::SetupRuntimePreset,
state: codewhale_config::SetupState,
) -> Result<String> {
if let Some(source) = config.runtime_preset_blocker(
app.config_path.as_deref(),
app.config_profile.as_deref(),
&app.workspace,
) {
anyhow::bail!(
"Runtime presets cannot override {source}; change that controlling source first"
);
}
if preset == crate::tui::setup::SetupRuntimePreset::HighTrustLocal {
let approval = config.approval_policy_control(
app.config_path.as_deref(),
app.config_profile.as_deref(),
&app.workspace,
);
if !approval.editable_root() {
anyhow::bail!(
"Full Access cannot override {}; change that controlling source first",
approval.label()
);
}
}
let settings_path = Settings::path().context("failed to resolve settings path")?;
let settings_snapshot = RuntimePresetFileSnapshot::capture(settings_path)?;
crate::settings::with_settings_transaction(|settings_transaction| {
let mut settings = settings_transaction
.load()
.context("failed to load settings")?;
settings.default_mode = preset.default_mode().to_string();
settings.permission_posture = Some(preset.permission_posture().to_string());
let selected_config_path =
crate::config::resolve_load_config_path(app.config_path.clone())?
.or_else(|| app.config_path.clone());
let config_path =
crate::config_persistence::config_toml_path(selected_config_path.as_deref())
.context("failed to resolve config path")?;
let config_snapshot = RuntimePresetFileSnapshot::capture(config_path.clone())?;
if let Err(error) =
crate::config_persistence::mutate_config_document(&config_path, |document| {
if let Some(policy) = preset.approval_policy() {
crate::config_persistence::set_document_value(
document,
&["approval_policy"],
policy,
)?;
} else {
crate::config_persistence::unset_document_value(
document,
&["approval_policy"],
)?;
}
crate::config_persistence::set_document_value(
document,
&["allow_shell"],
preset.allow_shell(),
)?;
crate::config_persistence::set_document_value(
document,
&["sandbox_mode"],
preset.sandbox_mode(),
)
})
.context("failed to persist runtime posture")
{
return Err(runtime_preset_error_with_rollback(
error,
&[&settings_snapshot, &config_snapshot],
));
}
if let Err(error) = settings_transaction
.save(&settings)
.context("failed to save settings")
{
return Err(runtime_preset_error_with_rollback(
error,
&[&settings_snapshot, &config_snapshot],
));
}
if let Err(error) = state
.save()
.context("failed to persist setup runtime posture state")
{
return Err(runtime_preset_error_with_rollback(
error,
&[&settings_snapshot, &config_snapshot],
));
}
Ok(())
})?;
if let Some(policy) = preset.approval_policy() {
config.approval_policy = Some(policy.to_string());
app.mark_approval_policy_locked();
} else {
config.approval_policy = None;
app.clear_saved_approval_policy_lock();
}
config.allow_shell = Some(preset.allow_shell());
config.sandbox_mode = Some(preset.sandbox_mode().to_string());
app.configured_sandbox_mode = config.sandbox_mode.clone();
let approval_mode = ApprovalMode::from_config_value(
preset
.approval_policy()
.unwrap_or(preset.permission_posture()),
)
.unwrap_or(ApprovalMode::Suggest);
let trust_mode = match preset {
crate::tui::setup::SetupRuntimePreset::AskFirst => false,
crate::tui::setup::SetupRuntimePreset::NormalAgent => app.agent_trust_baseline(),
crate::tui::setup::SetupRuntimePreset::HighTrustLocal => true,
};
app.set_agent_runtime_baseline(preset.allow_shell(), trust_mode, approval_mode);
let mode = AppMode::from_setting(preset.default_mode());
app.set_mode(mode);
app.needs_redraw = true;
Ok(format!("Applied {}.", preset.result_summary()))
}
pub(crate) fn apply_backtrack(app: &mut App, depth: usize) {
let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else {
app.status_message = Some("Backtrack target no longer present".to_string());
return;
};
let user_text = match app.history.get(history_idx) {
Some(HistoryCell::User { content }) => content.clone(),
_ => String::new(),
};
app.truncate_history_to(history_idx);
if let Some(idx) = backtrack_api_cut_index(&app.api_messages, depth) {
app.api_messages.truncate(idx);
}
app.input = user_text;
app.cursor_position = app.input.chars().count();
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
app.view_stack.pop();
}
app.status_message =
Some("Rewound to previous user message — edit and Enter to resend".to_string());
app.scroll_to_bottom();
app.mark_history_updated();
app.needs_redraw = true;
}
pub(crate) async fn apply_provider_picker_custom_provider(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
provider_id: String,
base_url: String,
model: Option<String>,
api_key_env: Option<String>,
) -> bool {
let written = match crate::config_persistence::persist_custom_provider(
app.config_path.as_deref(),
&provider_id,
&base_url,
model.as_deref(),
api_key_env.as_deref(),
) {
Ok(path) => path,
Err(err) => {
app.add_message(HistoryCell::System {
content: format!("Failed to save custom provider {provider_id}: {err}"),
});
app.status_message = Some("Custom provider was not saved.".to_string());
return false;
}
};
config.provider = Some(provider_id.clone());
let entry = config
.providers
.get_or_insert_with(ProvidersConfig::default)
.custom
.entry(provider_id.clone())
.or_default();
entry.kind = Some("openai-compatible".to_string());
entry.base_url = Some(base_url.trim().trim_end_matches('/').to_string());
entry.model = model.clone().and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
});
entry.api_key_env = api_key_env.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
});
app.status_message = Some(format!(
"Custom provider {provider_id} saved to {}",
written.display()
));
switch_provider(app, engine_handle, config, ApiProvider::Custom, model).await
}
pub(crate) async fn apply_provider_picker_api_key(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
identity: crate::config::ProviderIdentity,
api_key: String,
base_url: Option<String>,
) {
apply_provider_picker_api_key_with_verifier(
app,
engine_handle,
config,
identity,
api_key,
base_url,
&LiveProviderKeyVerifier,
)
.await;
}
pub(crate) async fn apply_provider_picker_api_key_with_verifier(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
identity: crate::config::ProviderIdentity,
api_key: String,
base_url_override: Option<String>,
verifier: &dyn ProviderKeyVerifier,
) {
let provider = identity.provider;
let mut scoped_config = config.clone();
scoped_config.provider = Some(identity.key.clone());
if let Some(base_url) = base_url_override.clone() {
scoped_config.set_provider_base_url_override(provider, Some(base_url));
}
let base_url = scoped_config.deepseek_base_url();
match verifier.verify(provider, &api_key, &base_url).await {
Ok(()) => {
let runtime_status = query_provider_runtime_status(engine_handle).await;
if let Some(picker) =
crate::tui::provider_picker::ProviderPickerView::new_for_model_pick_after_validation(
app.api_provider,
provider,
&scoped_config,
runtime_status,
api_key,
base_url_override,
)
.map(|picker| {
picker
.with_locale(app.ui_locale)
.with_provider_health(&app.provider_health)
})
{
app.view_stack.push(picker);
app.status_message = Some(format!(
"{} API key verified — pick a default model.",
provider.as_str()
));
} else {
app.status_message = Some(format!(
"{} API key verified, but the guided setup could not be re-opened.",
provider.as_str()
));
}
app.needs_redraw = true;
}
Err(reason) => {
let runtime_status = query_provider_runtime_status(engine_handle).await;
if let Some(picker) =
crate::tui::provider_picker::ProviderPickerView::new_for_key_entry_with_error(
app.api_provider,
provider,
&scoped_config,
runtime_status,
reason,
)
.map(|picker| {
picker
.with_locale(app.ui_locale)
.with_provider_health(&app.provider_health)
})
{
app.view_stack.push(picker);
app.status_message = Some(format!(
"{} API key verification failed - check the key and try again.",
provider.as_str()
));
} else {
app.status_message = Some(format!(
"{} API key verification failed, but the provider could not be re-opened.",
provider.as_str()
));
}
app.needs_redraw = true;
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_provider_picker_setup_confirmed(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
identity: crate::config::ProviderIdentity,
api_key: String,
model: String,
context_window: Option<u32>,
base_url: Option<String>,
) -> bool {
use crate::config::{
save_api_key_for_identity, save_provider_base_url_for_identity,
save_provider_context_window_for_identity, save_provider_model_for_identity,
};
let provider = identity.provider;
let model = model.trim().to_string();
if model.is_empty() {
app.add_message(HistoryCell::System {
content: format!(
"Cannot finish {} setup: default model is empty.\nProvider unchanged.",
provider.as_str()
),
});
return false;
}
if let Some(base_url) = base_url.as_deref() {
if let Err(err) = save_provider_base_url_for_identity(&identity, config, base_url) {
app.add_message(HistoryCell::System {
content: format!(
"Failed to save {} endpoint `{base_url}`: {err}\nProvider unchanged.",
provider.as_str()
),
});
return false;
}
config.set_provider_base_url_override(provider, Some(base_url.to_string()));
}
let mut save_confirmation = None;
match save_api_key_for_identity(&identity, config, &api_key) {
Ok(saved) => {
let destination = saved.describe();
if let Err(err) = save_provider_model_for_identity(&identity, config, &model) {
app.add_message(HistoryCell::System {
content: format!(
"Saved {} API key to {destination} (available in all folders), but failed to pin model `{model}`: {err}",
provider.as_str(),
),
});
} else if let Some(context_window) = context_window {
if let Err(err) =
save_provider_context_window_for_identity(&identity, config, context_window)
{
app.add_message(HistoryCell::System {
content: format!(
"Saved {} API key and model to {destination} (available in all folders), but failed to save context window: {err}",
provider.as_str(),
),
});
} else {
save_confirmation = Some(format!(
"Saved {} API key, model, and context window to {destination} (available in all folders)",
provider.as_str(),
));
}
} else {
save_confirmation = Some(format!(
"Saved {} API key and model to {destination} (available in all folders)",
provider.as_str(),
));
}
app.api_key_env_only = false;
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!(
"Failed to save {} API key: {err}\nProvider unchanged.",
provider.as_str()
),
});
return false;
}
}
config.provider = Some(identity.key);
mirror_saved_api_key_in_config(config, provider, api_key);
mirror_saved_model_in_config(config, provider, model.clone());
if let Some(context_window) = context_window {
mirror_saved_context_window_in_config(config, provider, context_window);
}
let switched = switch_provider(app, engine_handle, config, provider, Some(model)).await;
if switched && let Some(confirmation) = save_confirmation {
app.status_message = Some(confirmation);
}
switched
}
pub(crate) async fn apply_codewhale_owned_xai_login(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
pending: crate::xai_oauth::PendingXaiDeviceLogin,
status_prefix: &str,
) -> bool {
match crate::xai_oauth::activate_device_login(
pending,
app.config_path.as_deref(),
Some(&mut *config),
) {
Ok(activation) => {
app.status_message = Some(format!(
"{status_prefix}; activated {} via {}",
codewhale_config::quote_os_path(&activation.auth_path),
codewhale_config::quote_os_path(&activation.config_path)
));
app.api_key_env_only = false;
}
Err(err) => {
app.add_message(HistoryCell::System {
content: format!(
"Failed to finalize {} device login: {err:#}\nProvider unchanged.",
ApiProvider::Xai.as_str()
),
});
return false;
}
}
switch_provider(app, engine_handle, config, ApiProvider::Xai, None).await
}
pub(crate) fn apply_loaded_session(
app: &mut App,
config: &mut Config,
session: &SavedSession,
) -> Result<(), String> {
if app.session_transition_blocked() {
return Err(
"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(),
);
}
let provider_identity = config.resolve_persisted_provider_identity(
Some(&session.metadata.model_provider),
session.metadata.model_provider_id.as_deref(),
)?;
let restored_route = resolve_runtime_route_for_identity(
config,
&provider_identity,
Some(&session.metadata.model),
)
.map_err(|reason| {
format!(
"saved session provider '{}' could not be resolved from the live config: {reason}. Codewhale will not fall back",
provider_identity.key
)
})?;
app.restore_work_state(
&session.metadata.id,
&session.metadata.workspace,
session.work_state.as_ref(),
)?;
let _settled_old_cost_scope = crate::cost_status::close_current_scope();
*config = *restored_route.config;
app.api_messages = crate::runtime_handoff::project_messages_for_restore(&session.messages);
app.clear_history();
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.active_cell = None;
app.active_tool_details.clear();
app.active_tool_entry_completed_at.clear();
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
app.exploring_cell = None;
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.pending_tool_uses.clear();
app.last_exec_wait_command = None;
let messages = app.api_messages.clone();
let mut message_to_cell = std::collections::HashMap::new();
for (message_index, msg) in messages.iter().enumerate() {
let mut cells = history_cells_from_message(msg);
if msg.role == "user"
&& session
.context_references
.iter()
.any(|record| record.message_index == message_index)
{
for cell in &mut cells {
if let HistoryCell::User { content } = cell {
*content = compact_user_context_display(content);
}
}
}
let base = app.history.len();
if msg.role == "user"
&& let Some(offset) = cells
.iter()
.position(|cell| matches!(cell, HistoryCell::User { .. }))
{
message_to_cell.insert(message_index, base + offset);
}
app.extend_history(cells);
}
app.sync_context_references_from_session(&session.context_references, &message_to_cell);
app.mark_history_updated();
app.viewport.transcript_selection.clear();
restore_loaded_session_provider(app, config, provider_identity);
app.set_model_selection(session.metadata.model.clone());
if app.auto_model
&& let Some(saved) = session.last_auto_route.as_ref()
&& !saved.provider_identity.trim().is_empty()
&& !saved.model.trim().is_empty()
{
app.last_effective_provider = Some(saved.provider);
app.last_effective_provider_identity = Some(saved.provider_identity.clone());
app.last_effective_model = Some(saved.model.clone());
app.last_auto_route_receipt = Some(saved.receipt.clone());
app.last_effective_reasoning_effort = saved.effective_reasoning_effort.map(Into::into);
}
resolve_loaded_session_route(app, config);
if !app.auto_model {
let requested = app
.reasoning_effort_preference
.unwrap_or(app.reasoning_effort);
app.reasoning_effort =
requested.normalize_for_route(app.api_provider, &app.active_route_base_url, &app.model);
}
app.provider_models.insert(
app.provider_identity_for_persistence().to_string(),
app.model_selection_for_persistence(),
);
app.update_model_compaction_budget();
apply_workspace_runtime_state(app, config, session.metadata.workspace.clone());
if let Some(mode) = session.metadata.mode.as_deref().and_then(AppMode::parse) {
app.set_mode(mode);
}
app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
app.session.total_conversation_tokens = app.session.total_tokens;
let restored_parent = crate::pricing::CostEstimate {
usd: session.metadata.cost.session_cost_usd,
cny: session.metadata.cost.session_cost_cny,
}
.sanitized();
let restored_background = crate::pricing::CostEstimate {
usd: session.metadata.cost.subagent_cost_usd,
cny: session.metadata.cost.subagent_cost_cny,
}
.sanitized();
app.session.session_cost = restored_parent.usd;
app.session.session_cost_cny = restored_parent.cny;
app.session.subagent_cost = restored_background.usd;
app.session.subagent_cost_cny = restored_background.cny;
app.session.subagent_cost_event_seqs.clear();
app.reset_cost_coverage();
app.session.cost_priced_turns = session.metadata.cost.priced_turns;
app.session.cost_unpriced_turns = session.metadata.cost.unpriced_turns;
app.session.cost_cny_priced_turns = session.metadata.cost.cny_priced_turns;
app.session.cost_cny_unpriced_turns = session.metadata.cost.cny_unpriced_turns;
app.session.cost_unpriced_reasons = session.metadata.cost.unpriced_reasons.clone();
app.session.cost_cny_unpriced_reasons = session.metadata.cost.cny_unpriced_reasons.clone();
app.session.cost_unpriced_classes = session.metadata.cost.unpriced_classes.clone();
app.session.cost_pricing_provenances = session.metadata.cost.pricing_provenances.clone();
app.session.cost_live_pricing_defects = session.metadata.cost.live_pricing_defects.clone();
app.session.cost_live_pricing_unusable_defects =
session.metadata.cost.live_pricing_unusable_defects.clone();
app.session.cost_route_receipts = session.metadata.cost.route_receipts.clone();
app.session.cost_coverage_unknown_legacy = session.metadata.cost.coverage_is_legacy_unknown();
let total_restored_usd = session.metadata.cost.total_usd();
let total_restored_cny = session.metadata.cost.total_cny();
let restored_high_water = crate::pricing::CostEstimate {
usd: session.metadata.cost.displayed_cost_high_water_usd,
cny: session.metadata.cost.displayed_cost_high_water_cny,
}
.sanitized();
app.session.displayed_cost_high_water = restored_high_water.usd.max(total_restored_usd);
app.session.displayed_cost_high_water_cny = restored_high_water.cny.max(total_restored_cny);
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
app.session.reset_token_breakdown();
app.session.turn_cache_history.clear();
app.cumulative_turn_duration =
std::time::Duration::from_secs(session.metadata.cumulative_turn_secs);
app.current_session_id = Some(session.metadata.id.clone());
app.current_session_metadata = Some(session.metadata.clone());
app.session_artifacts = session.artifacts.clone();
app.session_title = Some(session.metadata.title.clone());
app.workspace_context = None;
app.workspace_context_refreshed_at = None;
if let Some(sp) = session.system_prompt.as_ref() {
app.system_prompt = Some(SystemPrompt::Text(sp.clone()));
} else {
app.system_prompt = None;
}
app.scroll_to_bottom();
Ok(())
}
pub(crate) fn apply_loaded_session_config_snapshot(
app: &mut App,
config: &mut Config,
session: &SavedSession,
mut next_config: Config,
force_engine_respawn: bool,
) -> Result<bool, String> {
if force_engine_respawn {
let mut effective_config = config.clone();
effective_config.refresh_provider_routes_from(&next_config);
next_config = effective_config;
}
let previous_provider = app.api_provider;
let previous_provider_identity = app.provider_identity_for_persistence().to_string();
let previous_workspace = app.workspace.clone();
apply_loaded_session(app, &mut next_config, session)?;
let respawn = force_engine_respawn
|| loaded_session_requires_engine_respawn(
app,
previous_provider,
&previous_provider_identity,
&previous_workspace,
);
*config = next_config;
Ok(respawn)
}