返回 CodeWhale
init.rs
根目录 / crates / tui / src / tui / app / init.rs
1 //! Application initialization: `App` construction lives here so the central
2 //! `app.rs` module holds state and behavior rather than a ~830-line
3 //! constructor. `App::new` remains a thin test-only shim over
4 //! [`App::new_with_plugin_registry`]; all callers construct `App` exactly as
5 //! before.
6
7 use super::*;
8
9 impl App {
10 /// Install the Config owner's current policy and refresh its read-only UI projection.
11 pub(crate) fn refresh_notification_settings(&mut self, config: &Config) {
12 self.notification_settings = config.notifications_config();
13 let _ = crate::tui::notifications::settings(config);
14 }
15
16 #[cfg(test)]
17 pub fn new(options: TuiOptions, config: &Config) -> Self {
18 let workspace = options.workspace.clone();
19 Self::new_with_plugin_registry(
20 options,
21 config,
22 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace)),
23 )
24 }
25
26 #[allow(clippy::too_many_lines)]
27 pub fn new_with_plugin_registry(
28 options: TuiOptions,
29 config: &Config,
30 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
31 ) -> Self {
32 let TuiOptions {
33 model,
34 workspace,
35 config_path,
36 config_profile,
37 allow_shell,
38 screen_mode,
39 use_mouse_capture,
40 mouse_capture_preference,
41 use_bracketed_paste,
42 max_subagents,
43 skills_dir: global_skills_dir,
44 memory_path,
45 notes_path: _,
46 mcp_config_path,
47 use_memory,
48 start_in_agent_mode,
49 skip_onboarding,
50 yolo,
51 resume_session_id,
52 initial_input,
53 // Consumed by `run_app` after the App exists, so it can be shown
54 // alongside (or instead of) the resume receipt.
55 startup_notice: _,
56 } = options;
57
58 // Start from disk-only preferences so one-time migrations can never
59 // persist terminal/environment overlays such as NO_ANIMATIONS. Apply
60 // those overlays only after any normalized settings write succeeds.
61 let mut settings = Settings::load_persisted().unwrap_or_else(|_| Settings::default());
62 let legacy_yolo_default = settings.legacy_yolo_default_detected();
63 let legacy_yolo_full_access = if legacy_yolo_default {
64 let control = config.approval_policy_control(
65 config_path.as_deref(),
66 config_profile.as_deref(),
67 &workspace,
68 );
69 match control {
70 crate::config::ApprovalPolicyControl::Unset => {
71 if let Err(error) = normalize_legacy_yolo_settings() {
72 tracing::warn!(
73 "failed to normalize legacy YOLO settings; retrying next launch: {error:#}"
74 );
75 }
76 true
77 }
78 crate::config::ApprovalPolicyControl::RootConfig => {
79 let active_config_path = match crate::config::resolve_load_config_path(
80 config_path.clone(),
81 ) {
82 Ok(path) => path,
83 Err(error) => {
84 tracing::error!(
85 error = %error,
86 "could not resolve the active config path for legacy policy migration"
87 );
88 None
89 }
90 };
91 match crate::config_persistence::persist_unset_root_key(
92 active_config_path.as_deref(),
93 "approval_policy",
94 ) {
95 Ok(_) => {
96 if let Err(error) = normalize_legacy_yolo_settings() {
97 tracing::warn!(
98 "removed legacy approval_policy but could not normalize settings; retrying next launch: {error:#}"
99 );
100 }
101 true
102 }
103 Err(error) => {
104 tracing::warn!(
105 "could not migrate legacy YOLO approval policy; keeping the controlling policy: {error:#}"
106 );
107 false
108 }
109 }
110 }
111 source => {
112 tracing::warn!(
113 "legacy YOLO setting was not allowed to override {}",
114 source.label()
115 );
116 false
117 }
118 }
119 } else {
120 false
121 };
122 settings.apply_env_overrides();
123 // Config::load resolves this once for every runtime. Direct in-memory
124 // callers use the same policy here, before any startup route is used.
125 let mut startup_config = config.clone();
126 if config_profile.is_some()
127 || (startup_config.remembered_selection_scope.is_none()
128 && (crate::config::explicit_launch_provider_override().is_some()
129 || crate::config::explicit_launch_model_override().is_some()))
130 {
131 startup_config.remembered_selection_scope = Some(false);
132 }
133 let selected = startup_config.apply_saved_selection(&settings);
134 let config = &startup_config;
135 let model = if selected {
136 config.default_model()
137 } else {
138 model
139 };
140 // Tideline Startup is the fresh interactive landing surface. It must
141 // not be bypassed by a stale historical `launch_screen = false`, a
142 // provider/config notice, or a previous session record: only an
143 // intentional resume or explicit initial input enters the live session
144 // path directly.
145 let launch_visible = resume_session_id.is_none() && initial_input.is_none();
146 let launch = LaunchState::new(launch_visible, &workspace);
147
148 // If settings.toml exists on disk but couldn't be parsed (we fell back
149 // to defaults), surface a warning in the TUI so the user knows their
150 // file is broken instead of silently losing all settings.
151 let settings_parse_warning = crate::settings::Settings::path().ok().and_then(|p| {
152 if p.exists() {
153 std::fs::read_to_string(&p).ok().and_then(|raw| {
154 ::toml::from_str::<::toml::Value>(&raw)
155 .err()
156 .map(|e| format!("⚠ settings.toml is malformed — using defaults ({e})"))
157 })
158 } else {
159 None
160 }
161 });
162 let provider = config.api_provider();
163 let provider_identity_record =
164 config
165 .active_provider_identity(provider)
166 .unwrap_or_else(|_| {
167 let key = config.provider_identity_for(provider);
168 let exact_id = (!(provider == ApiProvider::Custom
169 && config.uses_legacy_literal_custom_route()))
170 .then(|| key.clone());
171 crate::config::ProviderIdentity {
172 provider,
173 key,
174 exact_id,
175 migrated_legacy_ollama_cloud_route: false,
176 }
177 });
178 let mut effective_auth_config = config.clone();
179 effective_auth_config.scope_to_provider_identity(&provider_identity_record);
180 let provider_identity = provider_identity_record.key;
181 let provider_exact_id = provider_identity_record.exact_id;
182
183 // #5032: a stale `[providers.xai] oauth_credential_generation` pointer
184 // whose owned credential file is gone makes `credentials_valid` return
185 // false with no recovery, so the generic provider picker reopened on
186 // EVERY launch (the dogfood bricked state). Detect that specific
187 // corrupted state, best-effort clear the stale pointer from the
188 // persisted config, and surface a truthful xAI-specific message. The
189 // repair never blocks or aborts launch; after it the state is the
190 // normal "needs auth", not a bricked loop.
191 // #5032: an onboarded user whose active xAI OAuth credential is missing
192 // must be guided to re-authenticate THAT provider — not be re-run through
193 // the generic provider picker on every launch. Detect the missing-cred
194 // state (broader than a dangling pointer: it also covers a repaired
195 // pointer, an expired/revoked token, or a never-completed login), repair
196 // a stale pointer once, surface a truthful xAI message, and suppress the
197 // picker-recovery path below.
198 let xai_oauth_needs_reauth = provider == ApiProvider::Xai
199 && effective_auth_config
200 .provider_config_for(ApiProvider::Xai)
201 .and_then(|entry| entry.auth_mode.as_deref())
202 .is_some_and(crate::oauth::auth_mode_uses_xai_oauth)
203 && !crate::oauth::credentials_present(
204 crate::oauth::OAuthProvider::Xai,
205 &effective_auth_config,
206 );
207 let xai_dangling_repair_message = if xai_oauth_needs_reauth {
208 if crate::oauth::owned_generation_is_dangling(
209 crate::oauth::OAuthProvider::Xai,
210 &effective_auth_config,
211 ) {
212 match crate::oauth::clear_dangling_generation(
213 crate::oauth::OAuthProvider::Xai,
214 config_path.as_deref(),
215 ) {
216 Ok(()) => {
217 // Keep the in-memory route consistent with the repaired
218 // persisted file so the running app never reaches for
219 // the missing generation.
220 effective_auth_config
221 .provider_config_for_mut(ApiProvider::Xai)
222 .oauth_credential_generation = None;
223 }
224 Err(error) => {
225 tracing::warn!(
226 target: "codewhale::xai_oauth",
227 error = %error,
228 "could not clear the dangling xAI OAuth generation pointer; continuing launch"
229 );
230 }
231 }
232 }
233 Some(
234 "⚠ xAI OAuth credentials are missing. Re-authenticate with \
235 `codewhale auth xai-device` or the in-app login, or switch providers."
236 .to_string(),
237 )
238 } else {
239 None
240 };
241 let model_ids_passthrough = effective_auth_config.model_ids_pass_through();
242 let provider_chain = provider
243 .kind()
244 .map(|kind| ProviderChain::new(kind, &config.fallback_providers))
245 .filter(|chain| chain.providers().len() > 1);
246
247 // Snapshot per-provider readiness for the fallback chain (#2574). Uses
248 // the same `has_api_key_for` helper the provider picker uses, so hosted
249 // providers require a key and self-hosted ones (Ollama/vLLM/SGLang) are
250 // reported ready without one. Empty when there is no fallback chain.
251 let provider_readiness = provider_chain
252 .as_ref()
253 .map(|chain| {
254 chain
255 .providers()
256 .iter()
257 .map(|kind| {
258 let provider = ApiProvider::from_kind(*kind);
259 (provider, has_api_key_for(config, provider))
260 })
261 .collect()
262 })
263 .unwrap_or_default();
264
265 // Check if the effective provider has an API key. This must happen
266 // after settings.default_provider is applied; otherwise a saved
267 // third-party provider can be pushed back into DeepSeek onboarding.
268 let needs_api_key = !has_api_key(&effective_auth_config);
269 let api_key_env_only =
270 crate::config::active_provider_uses_env_only_api_key(&effective_auth_config);
271 let was_onboarded = crate::tui::onboarding::is_onboarded();
272 let settings_auto_compact = settings.auto_compact;
273 let auto_compact_user_configured = Settings::auto_compact_explicitly_configured();
274 let auto_compact_threshold_percent = settings.auto_compact_threshold_percent;
275 let compaction_summary_instructions = config.compaction_summary_instructions();
276 let compaction_retained_user_message_tokens =
277 config.compaction_retained_user_message_tokens();
278 let calm_mode = settings.calm_mode;
279 let low_motion = settings.low_motion;
280 let constrained_frame_rate = settings.constrained_frame_rate;
281 let fancy_animations = settings.fancy_animations;
282 let focus_texture =
283 crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture)
284 .unwrap_or_default();
285 let work_surface_placement =
286 crate::tui::work_surface::WorkSurfacePlacement::parse(&settings.work_surface_placement);
287 let work_surface_top_height = settings.work_surface_top_height;
288 let work_surface_side_width = settings.work_surface_side_width;
289 let synchronized_output_enabled = settings.synchronized_output_enabled();
290 let status_indicator = settings.status_indicator.clone();
291 let show_thinking = settings.show_thinking;
292 let thinking_highlight = settings.thinking_highlight;
293 let thinking_default_expanded = settings.thinking_default_expanded;
294 let thinking_preview_lines = settings.thinking_preview_lines;
295 let help_expand_groups = settings.help_expand_groups;
296 let pin_last_prompt = settings.pin_last_prompt;
297 let show_tool_details = settings.show_tool_details;
298 let inline_diff_mode = InlineDiffMode::parse(&settings.inline_diffs);
299 let ui_locale = resolve_locale(&settings.locale);
300 // The dead `tui.toml` store was folded into settings.toml on load.
301 // Say so once, in the user's language, rather than letting a theme
302 // move under them unexplained.
303 let tui_prefs_migration_notice = settings
304 .tui_prefs_migration()
305 .map(|receipt| receipt.lines(ui_locale).join(" "))
306 .filter(|line| !line.is_empty());
307 let cost_currency = match (settings.cost_currency.as_str(), ui_locale.tag()) {
308 ("usd", "zh-Hans") => CostCurrency::Cny,
309 _ => CostCurrency::from_setting(&settings.cost_currency).unwrap_or(CostCurrency::Usd),
310 };
311 let composer_density = ComposerDensity::from_setting(&settings.composer_density);
312 let composer_border = settings.composer_border;
313 let composer_multiline_mode = settings.composer_multiline_mode;
314 let composer_vim_enabled = settings
315 .composer_vim_mode
316 .trim()
317 .eq_ignore_ascii_case("vim");
318 let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
319 let max_input_history = settings.max_input_history;
320 // Requesting bracketed paste does not prove the terminal delivers it.
321 // Keep the fallback until handle_paste_burst_key observes a real paste
322 // via bracketed_paste_seen; otherwise raw pasted newlines can submit.
323 let use_paste_burst_detection = settings.paste_burst_detection;
324 // Resolve the named theme from settings; unknown values were already
325 // normalised to the underwater default in Settings::load. The
326 // background_color setting still overlays on top.
327 let background_color_override = settings
328 .background_color
329 .as_deref()
330 .and_then(palette::parse_hex_rgb_color);
331 let background_setting = background_color_override.and_then(palette::hex_rgb_string);
332 let resolved_theme =
333 palette::resolve_theme_setting(&settings.theme, background_setting.as_deref());
334 let theme_warning = resolved_theme.as_ref().err().map(|error| {
335 format!(
336 "⚠ configured theme '{}' could not be loaded — using System ({error})",
337 settings.theme
338 )
339 });
340 let (theme_name, theme_id, ui_theme) = resolved_theme.unwrap_or_else(|_| {
341 let id = palette::ThemeId::System;
342 let mut theme = id.ui_theme();
343 if let Some(background) = background_color_override {
344 theme = theme.with_background_color(background);
345 }
346 (id.name().to_string(), id, theme)
347 });
348 // Remembered route choices were resolved once into Config. The
349 // chooser and hotbar must not revive archived Settings values.
350 let mut provider_models = HashMap::new();
351 for &candidate in ApiProvider::all() {
352 if candidate != ApiProvider::Custom
353 && let Some(model) = config
354 .provider_config_for(candidate)
355 .and_then(|entry| entry.model.as_ref())
356 {
357 provider_models.insert(config.provider_identity_for(candidate), model.clone());
358 }
359 }
360 if let Some(providers) = config.providers.as_ref() {
361 for (identity, entry) in &providers.custom {
362 if let Some(model) = entry.model.as_ref() {
363 provider_models.insert(identity.clone(), model.clone());
364 }
365 }
366 }
367 provider_models.insert(provider_identity.clone(), model.clone());
368 let auto_model = model.trim().eq_ignore_ascii_case("auto");
369 let mut enabled_provider_models = settings.enabled_models.clone().unwrap_or_default();
370 for (saved_provider, saved_model) in &provider_models {
371 push_enabled_provider_model(&mut enabled_provider_models, saved_provider, saved_model);
372 }
373 push_enabled_provider_model(&mut enabled_provider_models, &provider_identity, &model);
374 let active_context_window_override = config.context_window_for_provider_config(provider);
375 let active_model_context_windows = config.model_context_windows_for(provider).cloned();
376 let configured_route_base_url = effective_auth_config.active_route_base_url();
377 let (active_route_limits, active_route_base_url, active_context_window_source) =
378 if auto_model {
379 (
380 active_context_window_override.map(|window| RouteLimits {
381 context_tokens: Some(u64::from(window)),
382 ..RouteLimits::default()
383 }),
384 configured_route_base_url,
385 if active_context_window_override.is_some() {
386 crate::route_runtime::ContextWindowSource::Configured
387 } else {
388 crate::route_runtime::ContextWindowSource::Fallback
389 },
390 )
391 } else {
392 crate::route_runtime::resolve_runtime_route(
393 &effective_auth_config,
394 provider,
395 Some(&model),
396 )
397 .map(|resolution| {
398 (
399 crate::route_budget::known_route_limits(resolution.candidate.limits()),
400 resolution.candidate.endpoint().base_url.clone(),
401 resolution.context_window.source,
402 )
403 })
404 .unwrap_or((
405 None,
406 configured_route_base_url,
407 crate::route_runtime::ContextWindowSource::Fallback,
408 ))
409 };
410 let reasoning_effort_explicit = config.fleet_operator_reasoning_applied
411 || settings.reasoning_effort.is_some()
412 || config.reasoning_effort_is_explicit();
413 let configured_reasoning_effort = if config.fleet_operator_reasoning_applied {
414 config.reasoning_effort()
415 } else {
416 settings
417 .reasoning_effort
418 .as_deref()
419 .or_else(|| config.reasoning_effort())
420 };
421 let reasoning_effort_preference = configured_reasoning_effort
422 .filter(|_| reasoning_effort_explicit)
423 .map(ReasoningEffort::from_setting);
424 let threshold_model = if auto_model {
425 DEFAULT_TEXT_MODEL
426 } else {
427 model.as_str()
428 };
429 let compact_threshold = crate::route_budget::compaction_threshold_for_route_at_percent(
430 provider,
431 threshold_model,
432 active_route_limits,
433 auto_compact_threshold_percent,
434 );
435 let auto_compact = if auto_compact_user_configured {
436 settings_auto_compact
437 } else {
438 crate::route_budget::auto_compact_default_for_route(
439 provider,
440 threshold_model,
441 active_route_limits,
442 )
443 };
444 let mut reasoning_effort = if auto_model && !reasoning_effort_explicit {
445 // A retired fixed-model alias can infer a compatibility effort in
446 // Config. That is route metadata, not an explicit user preference,
447 // so it must not silently constrain unresolved auto routing.
448 ReasoningEffort::Auto
449 } else {
450 configured_reasoning_effort.map_or_else(
451 || {
452 if auto_model {
453 ReasoningEffort::Auto
454 } else {
455 ReasoningEffort::default()
456 }
457 },
458 |setting| {
459 if auto_model {
460 ReasoningEffort::from_setting(setting)
461 } else {
462 ReasoningEffort::from_setting_for_provider(setting, provider)
463 }
464 },
465 )
466 };
467 if !auto_model
468 && !reasoning_effort_explicit
469 && let Some(effort) = crate::config::legacy_deepseek_alias_effort_for_route(
470 provider,
471 &effective_auth_config.active_route_base_url(),
472 &model,
473 )
474 {
475 reasoning_effort = ReasoningEffort::from_setting_for_provider(effort, provider);
476 }
477 if !auto_model
478 && crate::config::is_exact_direct_moonshot_k3_route(
479 provider,
480 &active_route_base_url,
481 &model,
482 )
483 {
484 // Keep the visible/effective tier truthful on first launch too;
485 // direct K3 cannot honor a persisted `off` setting.
486 reasoning_effort =
487 reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model);
488 } else if !auto_model && !reasoning_effort_explicit {
489 if let Some(default) = ReasoningEffort::catalog_default(provider, &model) {
490 reasoning_effort = default;
491 }
492 } else if !auto_model && ReasoningEffort::catalog_effort_values(provider, &model).is_some()
493 {
494 reasoning_effort =
495 reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model);
496 }
497
498 // Resolve the saved mode separately from the permission posture.
499 let preferred_mode = AppMode::from_setting(&settings.default_mode);
500 // Legacy `default_mode = "yolo"` was split into Act plus the
501 // full-access posture at the settings edge, so only the CLI flag
502 // requests the compat elevation here.
503 let yolo_requested = yolo;
504 let initial_mode = if yolo_requested || start_in_agent_mode {
505 AppMode::Agent
506 } else {
507 preferred_mode
508 };
509
510 // Durable Agent-era permission baseline (#3386). Plan/YOLO derive from
511 // and restore to this. When the user starts in YOLO the live shell
512 // flag is force-enabled below, so
513 // the baseline shell value is taken from the interactive default (the
514 // pre-mode Agent surface) rather than the YOLO-forced live mirror;
515 // otherwise it mirrors the resolved `allow_shell` option, which already
516 // carries that same interactive default. Using `interactive_allow_shell()`
517 // here keeps the Agent baseline identical regardless of launch mode, so
518 // a YOLO -> Agent downshift exposes shell (approval-gated) exactly as
519 // documented, while an explicit `allow_shell = false` still hides it.
520 // Trust is never part of the Agent baseline (it is YOLO-only authority).
521 // Approval mirrors the configured policy.
522 let explicit_approval_mode = (!legacy_yolo_full_access)
523 .then_some(config.approval_policy.as_deref())
524 .flatten()
525 .and_then(ApprovalMode::from_config_value);
526 let approval_policy_control = if legacy_yolo_full_access {
527 ApprovalPolicyControl::Unset
528 } else {
529 config.approval_policy_control(
530 config_path.as_deref(),
531 config_profile.as_deref(),
532 &workspace,
533 )
534 };
535 let approval_policy_locked = approval_policy_control != ApprovalPolicyControl::Unset;
536 let approval_policy_root_editable =
537 approval_policy_control == ApprovalPolicyControl::RootConfig;
538 let approval_policy_requirements_managed =
539 approval_policy_control == ApprovalPolicyControl::Requirements;
540 let shell_access_editable = config
541 .allow_shell_control(
542 config_path.as_deref(),
543 config_profile.as_deref(),
544 &workspace,
545 )
546 .editable_root();
547 // YOLO is a permission change. A locked policy must not be sidestepped
548 // by --yolo, default_mode=yolo, /zidong, or Alt+Y.
549 let yolo_compat = yolo_requested && !approval_policy_locked;
550 let needs_workspace_trust = !yolo_compat && crate::tui::onboarding::needs_trust(&workspace);
551 // The language screen is required only when the locale cannot be
552 // confidently inferred from settings or the environment; returning
553 // users never see it.
554 let onboarding_needs_language = !was_onboarded
555 && !crate::tui::onboarding::locale_confidently_inferred(&settings.locale);
556 // Suppress the missing-key provider picker for the xAI-OAuth-missing-
557 // credential case: the user already chose xAI and just needs to
558 // re-authenticate it, not re-pick a provider every launch.
559 let (onboarding, onboarding_missing_key_recovery) = launch_onboarding_decision(
560 skip_onboarding,
561 was_onboarded,
562 onboarding_needs_language,
563 needs_api_key,
564 needs_workspace_trust,
565 xai_oauth_needs_reauth,
566 );
567 let onboarding_workspace_trust_gate = onboarding_is_workspace_trust_gate(
568 skip_onboarding,
569 was_onboarded,
570 needs_api_key,
571 needs_workspace_trust,
572 );
573 let saved_permission_posture = if approval_policy_locked {
574 None
575 } else {
576 settings
577 .permission_posture
578 .as_deref()
579 .and_then(ApprovalMode::from_config_value)
580 };
581 let configured_approval_mode = explicit_approval_mode
582 .or(saved_permission_posture)
583 .unwrap_or_default();
584 let configured_trust_mode = configured_approval_mode == ApprovalMode::Bypass;
585 let mode_prefs = ModeSessionPrefs {
586 agent_allow_shell: if yolo_compat {
587 config.interactive_allow_shell()
588 } else {
589 allow_shell
590 },
591 agent_trust_mode: configured_trust_mode,
592 // The YOLO-compat launch elevates the *live* approval mirror to
593 // Bypass below; the durable Agent baseline keeps the configured
594 // policy so a YOLO -> Agent downshift restores it.
595 agent_approval_mode: configured_approval_mode,
596 };
597 let allow_shell = if yolo_compat {
598 allow_shell || shell_access_editable
599 } else {
600 allow_shell
601 };
602 let shell_manager = new_shared_shell_manager(workspace.clone());
603
604 for error in crate::commands::user_registry::install_plugin_registry(
605 &workspace,
606 plugin_registry.as_ref(),
607 ) {
608 tracing::warn!(target: "plugins", "{error}");
609 }
610
611 // Initialize hooks executor from config, reviewed plugin snapshots,
612 // then project-local `.codewhale/hooks.toml` (#3026).
613 let hooks_config = crate::hooks::HooksConfig::load_with_project_and_plugins(
614 config.hooks_config(),
615 &workspace,
616 Some(plugin_registry.as_ref()),
617 );
618 let hooks = HookExecutor::new(hooks_config, workspace.clone());
619
620 // Initialize the lifecycle event outbox (`[lifecycle_outbox]`).
621 // Disabled (all emits no-op) when the config has no path.
622 let lifecycle_outbox = config
623 .lifecycle_outbox
624 .as_ref()
625 .map(|outbox| {
626 codewhale_hooks::LifecycleOutbox::new(
627 outbox.path.clone(),
628 outbox.webhook_url.clone(),
629 outbox.webhook_token.clone(),
630 )
631 })
632 .unwrap_or_else(codewhale_hooks::LifecycleOutbox::disabled);
633
634 // Initialize plan state
635 let plan_state = new_shared_plan_state();
636 let todos = new_shared_todo_list();
637 let work_runtime =
638 crate::work_graph::new_shared_work_runtime(todos.clone(), plan_state.clone());
639
640 let skills_scan_codewhale_only = config.skills_config().scan_codewhale_only();
641 let skills_dir = resolve_skills_dir(&workspace, &global_skills_dir, config);
642 let cached_skills = Self::discover_cached_skills(
643 &workspace,
644 &skills_dir,
645 skills_scan_codewhale_only,
646 plugin_registry.as_ref(),
647 );
648
649 let input_history = crate::composer_history::load_history();
650 let mention_cwd = std::env::current_dir().ok();
651 let start_remote_control = matches!(initial_input, Some(InitialInput::RemoteControl));
652 let (initial_input_text, initial_input_cursor, auto_submit_initial_input) =
653 match initial_input {
654 // #451: pre-populate the composer when invoked via
655 // `deepseek pr <N>` (or any future caller that wants to
656 // drop the model into a session with context already
657 // typed). Cursor lands at the end so Enter sends as-is.
658 Some(InitialInput::Prefill(text)) if !text.is_empty() => {
659 let cursor = text.chars().count();
660 (text, cursor, false)
661 }
662 Some(InitialInput::Submit(text)) if !text.is_empty() => {
663 let cursor = text.chars().count();
664 (text, cursor, true)
665 }
666 Some(InitialInput::RemoteControl) => (String::new(), 0, false),
667 _ => (String::new(), 0, false),
668 };
669 let (mcp_configured_count, mcp_connecting) =
670 crate::mcp::load_config_with_workspace_and_plugins(
671 &mcp_config_path,
672 &workspace,
673 plugin_registry.as_ref(),
674 )
675 .map(|cfg| {
676 // Boot is lazy (#6033): the pre-event "connecting" prediction
677 // is the eager set — `required` servers plus ones the user's
678 // `tools.always_load` selection covers — not every enabled
679 // server. The engine's first boot event replaces this with
680 // the real in-flight set.
681 let requested = config
682 .tools
683 .as_ref()
684 .map(|tools| {
685 tools
686 .always_load
687 .iter()
688 .map(|name| name.trim().to_ascii_lowercase())
689 .filter(|name| name.starts_with("mcp_"))
690 .collect::<Vec<_>>()
691 })
692 .unwrap_or_default();
693 let mut connecting = cfg
694 .servers
695 .iter()
696 .filter(|(_, server)| server.is_enabled())
697 .filter(|(name, server)| {
698 server.required
699 || crate::mcp::tool_selection_covers_server(&requested, name)
700 })
701 .map(|(name, _)| name.clone())
702 .collect::<Vec<_>>();
703 connecting.sort();
704 (cfg.servers.len(), connecting)
705 })
706 .unwrap_or((0, Vec::new()));
707 let mut hotbar_actions = HotbarActionRegistry::with_configured_routes(
708 config,
709 provider,
710 &model,
711 &provider_models,
712 );
713 // #2069: expose the already-discovered skills as bindable hotbar
714 // actions. Reuses the startup skill cache, so no extra filesystem I/O.
715 hotbar_actions.register_skills(&cached_skills);
716 let composer_arrows_scroll_explicit = config
717 .tui
718 .as_ref()
719 .and_then(|tui| tui.composer_arrows_scroll)
720 .is_some();
721 let mut app = Self {
722 mode: initial_mode,
723 hotbar_actions,
724 composer: ComposerState {
725 input: initial_input_text,
726 cursor_position: initial_input_cursor,
727 kill_buffer: String::new(),
728 paste_burst: PasteBurst::default(),
729 pending_paste_reference: None,
730 oversized_paste_full_text: None,
731 input_history,
732 draft_history: VecDeque::new(),
733 clear_undo_buffer: None,
734 history_index: None,
735 history_navigation_draft: None,
736 composer_history_search: None,
737 selected_attachment_index: None,
738 slash_menu_selected: 0,
739 slash_menu_hidden: false,
740 mention_menu_selected: 0,
741 mention_menu_hidden: false,
742 mention_completion_cache: None,
743 mention_discovery: crate::tui::mention_completion::MentionDiscovery::default(),
744 mention_cwd,
745 vim_enabled: composer_vim_enabled,
746 vim_mode: VimMode::Normal,
747 vim_pending_d: false,
748 selection_anchor: None,
749 // Seeded text was not typed, so it makes no command claim;
750 // startup integrity is decided by the replay receipt (#5925).
751 line_began_with_slash: false,
752 startup_input_unproven: false,
753 },
754 viewport: ViewportState {
755 selection_copy_markdown: config
756 .tui
757 .as_ref()
758 .and_then(|tui| tui.selection_copy_markdown)
759 .unwrap_or(true),
760 ..ViewportState::default()
761 },
762 pet_watch: crate::tui::pet_watch::PetWatch::default(),
763 work_surface: {
764 let mut state = crate::tui::work_surface::WorkSurfaceState::with_layout(
765 work_surface_placement,
766 work_surface_top_height,
767 work_surface_side_width,
768 );
769 state.panel = crate::tui::work_surface::RailPanel::parse(&settings.rail_panel);
770 state
771 },
772 goal: HostGoalState::default(),
773 session: SessionState::default(),
774 last_billed_input_tokens: None,
775 last_compaction: None,
776 active_allowed_tools: None,
777 pausable: false,
778 pending_route_save: None,
779 paused: false,
780 paused_goal_objective: None,
781 history: Vec::new(),
782 history_version: 0,
783 transcript_identity_epoch: 0,
784 history_revisions: Vec::new(),
785 tool_run_cache: ToolRunCache::default(),
786 next_history_revision: 1,
787 api_messages: Arc::new(Vec::new()),
788 api_message_stamps: Vec::new(),
789 session_journal: crate::session_tree::SessionJournal::new(),
790 completed_assistant_outputs: Vec::new(),
791 context_token_cache: std::cell::RefCell::new(Default::default()),
792 remote_control: crate::remote_control::RemoteControlController::default(),
793 start_remote_control_on_launch: start_remote_control,
794 is_loading: false,
795 dispatch_completion_tx: None,
796 dispatch_in_flight: false,
797 last_enter_instant: None,
798 provider_wait_incident_logged: false,
799 prompt_suggestion: None,
800 notification_settings: config.notifications_config(),
801 prompt_suggestion_gen: std::sync::atomic::AtomicU64::new(0),
802 offline_mode: false,
803 turn_error_posted: false,
804 // Surface parse warnings so the user knows their config file is
805 // broken instead of silently losing all settings.
806 status_message: xai_dangling_repair_message
807 .or(settings_parse_warning)
808 .or(tui_prefs_migration_notice)
809 .or(theme_warning),
810 status_toasts: VecDeque::new(),
811 update_available: None,
812 sticky_status: None,
813 last_status_message_seen: None,
814 context_pressure_warning_dismissed: None,
815 plugin_reload_nudge_stamp: None,
816 last_plugin_catalog_poll: None,
817 plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::from_settings(&settings),
818 model,
819 provider_models,
820 enabled_provider_models,
821 configured_models: config.custom_models.clone().unwrap_or_default(),
822 pinned_models: settings.pinned_models.clone(),
823 auto_model,
824 last_effective_model: None,
825 last_effective_provider: None,
826 last_effective_provider_identity: None,
827 last_auto_route_receipt: None,
828 pending_turn_route: None,
829 pending_auto_route_receipt: None,
830 active_turn: None,
831 api_provider: provider,
832 provider_identity,
833 provider_exact_id,
834 provider_chain,
835 provider_readiness,
836 provider_health: crate::provider_readiness::ProviderReadinessSnapshot::default(),
837 last_fallback_reason: None,
838 model_ids_passthrough,
839 active_route_limits,
840 active_route_base_url,
841 active_context_window_source,
842 active_context_window_override,
843 active_model_context_windows,
844 pending_provider_switch: None,
845 reasoning_effort,
846 reasoning_effort_preference,
847 last_effective_reasoning_effort: None,
848 workspace,
849 workflow_config: config.workflow_config(),
850 goal_max_continuations: config.goal_max_continuations(),
851 goal_enforce_token_budget: config.goal_enforce_token_budget(),
852 goal_continuation_waiting: false,
853 configured_sandbox_mode: config.sandbox_mode.clone(),
854 configured_sandbox_network: config.sandbox_network_access,
855 sandbox_backend: crate::sandbox::get_platform_sandbox_with_bwrap_preference(
856 config.prefer_bwrap.unwrap_or(false),
857 ),
858 // #4022: the worker thread is spawned lazily on first submit, so
859 // constructing an App never costs a thread.
860 lane_control: crate::lane_control::LaneControlQueue::new(),
861 plugin_registry,
862 config_path,
863 config_profile,
864 legacy_plugin_tools_dir: config
865 .tools
866 .as_ref()
867 .and_then(|tools| tools.plugin_dir.as_deref())
868 .map(PathBuf::from),
869 mcp_config_path: mcp_config_path.clone(),
870 skills_dir,
871 skills_scan_codewhale_only,
872 project_context_pack_enabled: config.project_context_pack_enabled(),
873 memory_path,
874 use_memory,
875 screen_mode,
876 use_mouse_capture,
877 mouse_capture_preference,
878 use_bracketed_paste,
879 use_paste_burst_detection,
880 bracketed_paste_seen: false,
881 system_prompt: None,
882 auto_compact,
883 auto_compact_user_configured,
884 auto_compact_threshold_percent,
885 compaction_summary_instructions,
886 compaction_retained_user_message_tokens,
887 stopped_turn: false,
888 calm_mode,
889 low_motion,
890 constrained_frame_rate,
891 ambient_clock_ms: 0,
892 ambient_clock_sampled_at: None,
893 ambient_idle_since: None,
894 ocean_completion_started_at: None,
895 ocean_turn_history_start: 0,
896 ocean_receipt_settle_start: None,
897 fancy_animations,
898 focus_texture,
899 launch,
900 pending_launch_action: None,
901 pending_composer_submit: None,
902 pending_hotbar_slot: None,
903 synchronized_output_enabled,
904 status_indicator,
905 show_thinking,
906 thinking_highlight,
907 thinking_default_expanded,
908 thinking_preview_lines,
909 help_expand_groups,
910 pin_last_prompt,
911 verbose_transcript: false,
912 show_tool_details,
913 inline_diff_mode,
914 ui_locale,
915 cost_currency,
916 billing_presentation: crate::route_billing::for_route(config, provider),
917 composer_density,
918 composer_border,
919 composer_multiline_mode,
920 voice_enabled: false,
921 voice_send_enabled: false,
922 voice_control_enabled: false,
923 transcript_spacing,
924 sidebar_hover: SidebarHoverState::default(),
925 sidebar_hover_tooltip: None,
926 model_picker_memory: None,
927 provider_picker_memory: None,
928 last_mouse_pos: None,
929 context_panel: settings.context_panel,
930 sessions_rail: settings.sessions_rail,
931 tool_collapse_threshold: 3,
932 expanded_tool_runs: HashSet::new(),
933 tool_collapse_mode: ToolCollapseMode::from_setting(&settings.tool_collapse_mode),
934 file_tree: None,
935 file_tree_visible: false,
936 compact_threshold,
937 max_input_history,
938 allow_shell,
939 verbosity: config.verbosity.clone(),
940 max_subagents,
941 stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(),
942 subagent_cache: Vec::new(),
943 subagent_terminal_seen_at: HashMap::new(),
944 agent_progress: HashMap::new(),
945 agent_progress_meta: HashMap::new(),
946 subagent_card_index: HashMap::new(),
947 last_fanout_card_index: None,
948 pending_subagent_dispatch: None,
949 agent_activity_started_at: None,
950 agent_counter: 0,
951 agent_label_map: HashMap::new(),
952 agent_focus: None,
953 agent_queued_follow_ups: HashMap::new(),
954 agent_role_counters: HashMap::new(),
955 last_agent_progress_redraw: None,
956 last_workflow_budget_redraw: None,
957 ui_theme,
958 background_color_override,
959 theme_id,
960 theme_name,
961 onboarding,
962 redaction_gate: false,
963 redaction_gate_confirming: false,
964 redaction_gate_scroll: std::cell::Cell::new(0),
965 onboarding_needs_api_key: needs_api_key,
966 onboarding_provider: provider,
967 onboarding_workspace_trust_gate,
968 onboarding_missing_key_recovery,
969 onboarding_explore_offline: false,
970 onboarding_had_language_step: onboarding_needs_language,
971 onboarding_had_provider_step: !was_onboarded && needs_api_key,
972 onboarding_had_trust_step: !was_onboarded && needs_workspace_trust,
973 api_key_env_only,
974 hooks,
975 lifecycle_outbox,
976 yolo: yolo_compat,
977 yolo_compat_notified: false,
978 startup_defaults: Default::default(),
979 keybinding_migration_notified: false,
980 mode_prefs,
981 approval_policy_locked,
982 approval_policy_root_editable,
983 approval_policy_requirements_managed,
984 shell_access_editable,
985 clipboard: ClipboardHandler::new(),
986 approval_session_approved: HashSet::new(),
987 approval_session_denied: HashSet::new(),
988 approval_mode: if yolo_compat {
989 ApprovalMode::Bypass
990 } else {
991 configured_approval_mode
992 },
993 view_stack: ViewStack::new(),
994 pending_user_input_prompt: None,
995 backtrack: crate::tui::backtrack::BacktrackState::new(),
996 current_session_id: None,
997 offline_queue_lease: None,
998 last_known_work_state: None,
999 last_known_goal_state: None,
1000 pending_goal_controls: VecDeque::new(),
1001 current_session_metadata: None,
1002 session_artifacts: Vec::new(),
1003 trust_mode: yolo_compat || configured_trust_mode,
1004 translation_enabled: false,
1005 mini_window: config.mini_window.clone().unwrap_or_default(),
1006 status_items: config
1007 .tui
1008 .as_ref()
1009 .and_then(|tui| tui.status_items.clone())
1010 .unwrap_or_else(crate::config::StatusItem::default_footer),
1011 posture_bar: config
1012 .tui
1013 .as_ref()
1014 .and_then(|tui| tui.posture_bar)
1015 .unwrap_or_default(),
1016 metrics_line: config
1017 .tui
1018 .as_ref()
1019 .and_then(|tui| tui.metrics_line)
1020 .unwrap_or(crate::config::ChromeRowPreset::Compact),
1021 // Prose wrap cap (`[transcript] prose_measure`, #5436). Resolved
1022 // once here so every render pass — main cache and full-screen
1023 // overlay — shares one effective width; `None` = full width.
1024 prose_measure: config.prose_measure(),
1025 project_doc: None,
1026 plan_state,
1027 todos,
1028 runtime_services: RuntimeToolServices {
1029 shell_manager: Some(shell_manager),
1030 work: Some(work_runtime),
1031 media_originals_dir: crate::media_originals::default_store_dir(),
1032 ..RuntimeToolServices::default()
1033 },
1034 coordination_detail: None,
1035 mcp_snapshot: None,
1036 mcp_initializing: !mcp_connecting.is_empty()
1037 && config.features().enabled(crate::features::Feature::Mcp),
1038 mcp_snapshot_generation: 0,
1039 mcp_snapshot_generation_invalidated: false,
1040 mcp_connecting,
1041 // Read the MCP config once at boot to know how many servers
1042 // the user has declared. The footer chip uses this even when
1043 // no live snapshot is available (#502). Cheap (just reads
1044 // the JSON files); errors fall through to zero so a missing
1045 // or malformed config simply hides the chip.
1046 mcp_configured_count,
1047 mcp_reload_required: false,
1048 mcp_reload_in_flight: false,
1049 tool_log: Vec::new(),
1050 active_skill: None,
1051 active_skill_provenance: None,
1052 cached_skills,
1053 tool_cells: HashMap::new(),
1054 tool_details_by_cell: HashMap::new(),
1055 context_references_by_cell: HashMap::new(),
1056 session_context_references: Vec::new(),
1057 active_cell: None,
1058 active_cell_revision: 0,
1059 active_tool_details: HashMap::new(),
1060 agent_roster: Vec::new(),
1061 agent_roster_session_id: None,
1062 agent_roster_print_requested: false,
1063 active_tool_entry_completed_at: HashMap::new(),
1064 exploring_cell: None,
1065 exploring_entries: HashMap::new(),
1066 ignored_tool_calls: HashSet::new(),
1067 last_exec_wait_command: None,
1068 streaming_message_index: None,
1069 streaming_source_receipt: None,
1070 suppress_stream_events_until_turn_complete: false,
1071 streaming_thinking_active_entry: None,
1072 thinking_revision_last_bump_at: None,
1073 streaming_state: StreamingState::new(),
1074 streaming_output_token_estimate: 0,
1075 reasoning_buffer: String::new(),
1076 reasoning_header: None,
1077 last_reasoning: None,
1078 pending_tool_uses: Vec::new(),
1079 pending_gate_receipts: Vec::new(),
1080 child_gate_receipts: std::collections::HashMap::new(),
1081 queued_messages: VecDeque::new(),
1082 queued_draft: None,
1083 pending_steers: VecDeque::new(),
1084 inflight_steers: VecDeque::new(),
1085 submit_pending_steers_after_interrupt: false,
1086 turn_started_at: None,
1087 turn_last_activity_at: None,
1088 cumulative_turn_duration: std::time::Duration::ZERO,
1089 session_metrics: crate::tui::session_metrics::SessionMetrics::default(),
1090 balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
1091 draft_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
1092 fleet_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
1093 constitution_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
1094 mcp_login: None,
1095 prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
1096 balance_initiated: false,
1097 last_balance_fetch: None,
1098 runtime_turn_id: None,
1099 runtime_turn_status: None,
1100 turn_counter: 0,
1101 dispatch_started_at: None,
1102 workspace_context: None,
1103 workspace_is_linked_worktree: false,
1104 workspace_context_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
1105 workspace_context_refreshed_at: None,
1106 memory_size_hint: None,
1107 task_panel: Vec::new(),
1108 task_panel_session_id: None,
1109 task_panel_unavailable: false,
1110 automation_panel: crate::tui::automation_panel::AutomationPanelState::default(),
1111 automation_scan: None,
1112 behavioral_tips: crate::tui::behavioral_tips::BehavioralTipState::new(
1113 settings.contextual_tips,
1114 ),
1115 footer_hint_uses: settings.footer_hint_uses.clone(),
1116 workflow_panel: None,
1117 session_started_at: chrono::Utc::now(),
1118 needs_redraw: true,
1119 fleet_roster_stale: false,
1120 force_next_full_repaint: false,
1121 thinking_started_at: None,
1122 is_compacting: false,
1123 active_compaction: None,
1124 manual_compaction_queued: false,
1125 manual_compaction_id: None,
1126 deferred_manual_compaction: None,
1127 is_purging: false,
1128 user_scrolled_during_stream: false,
1129 last_send_at: None,
1130 last_submitted_prompt: None,
1131 auto_submit_initial_input,
1132 quit_armed_until: None,
1133 prefix_change_count: 0,
1134 prefix_checks_total: 0,
1135 prefix_stability_pct: None,
1136 last_prefix_change_desc: None,
1137 last_pinned_prefix_hash: None,
1138 prefix_pin_reason: None,
1139 prefix_last_miss_reason: None,
1140 prefix_drift_count: 0,
1141 prefix_context_updates: 0,
1142 collapsed_cells: HashSet::new(),
1143 folded_thinking: HashSet::new(),
1144 collapsed_cell_map: Vec::new(),
1145 edit_in_progress: false,
1146 lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true),
1147 lsp_repair: LspRepairState::default(),
1148 composer_arrows_scroll: config
1149 .tui
1150 .as_ref()
1151 .and_then(|tui| tui.composer_arrows_scroll)
1152 .unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)),
1153 composer_arrows_scroll_explicit,
1154 mention_menu_limit: settings.mention_menu_limit,
1155 mention_walk_depth: settings.mention_walk_depth,
1156 mention_menu_behavior: settings.mention_menu_behavior.clone(),
1157 workspace_follow_symlinks: settings.workspace_follow_symlinks,
1158 session_title: None,
1159 window_title: None,
1160 title_default: config
1161 .title
1162 .as_deref()
1163 .map(crate::session_manager::sanitize_session_title)
1164 .map(|title| title.trim().to_string())
1165 .filter(|title| !title.is_empty()),
1166 receipt_text: None,
1167 receipt_started_at: None,
1168 tool_evidence: Vec::new(),
1169 };
1170 if yolo_compat {
1171 app.notify_yolo_compat_once();
1172 }
1173 app
1174 }
1175 }
1176
1177 /// Rewrite `settings.toml` with the legacy `default_mode = "yolo"` value
1178 /// normalized away.
1179 ///
1180 /// The normalization happens during parsing, so an empty transaction *is* the
1181 /// migration: load (which normalizes), then save. Doing it as its own
1182 /// [`crate::settings::Settings::transact`] rather than saving the snapshot
1183 /// `App::new` already loaded matters twice over. It cannot write back a stale
1184 /// pre-image, and — because `App::new` runs on the same hot path as several
1185 /// hundred tests — it keeps the transaction lock out of the common construction
1186 /// path entirely, taking it only when a legacy file actually needs migrating.
1187 fn normalize_legacy_yolo_settings() -> anyhow::Result<()> {
1188 crate::settings::Settings::transact(|_normalized_on_load| Ok(()))
1189 }
1190
1190 lines RUST