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