返回 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 #[cfg(test)]
11 pub fn new(options: TuiOptions, config: &Config) -> Self {
12 let workspace = options.workspace.clone();
13 Self::new_with_plugin_registry(
14 options,
15 config,
16 std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace)),
17 )
18 }
19
20 #[allow(clippy::too_many_lines)]
21 pub fn new_with_plugin_registry(
22 options: TuiOptions,
23 config: &Config,
24 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
25 ) -> Self {
26 let TuiOptions {
27 model,
28 workspace,
29 config_path,
30 config_profile,
31 allow_shell,
32 use_alt_screen,
33 use_mouse_capture,
34 use_bracketed_paste,
35 max_subagents,
36 skills_dir: global_skills_dir,
37 memory_path,
38 notes_path: _,
39 mcp_config_path,
40 use_memory,
41 start_in_agent_mode,
42 skip_onboarding,
43 yolo,
44 resume_session_id,
45 initial_input,
46 // Consumed by `run_app` after the App exists, so it can be shown
47 // alongside (or instead of) the resume receipt.
48 startup_notice: _,
49 } = options;
50
51 // Start from disk-only preferences so one-time migrations can never
52 // persist terminal/environment overlays such as NO_ANIMATIONS. Apply
53 // those overlays only after any normalized settings write succeeds.
54 let mut settings = Settings::load_persisted().unwrap_or_else(|_| Settings::default());
55 let legacy_yolo_default = settings.legacy_yolo_default_detected();
56 let legacy_yolo_full_access = if legacy_yolo_default {
57 let control = config.approval_policy_control(
58 config_path.as_deref(),
59 config_profile.as_deref(),
60 &workspace,
61 );
62 match control {
63 crate::config::ApprovalPolicyControl::Unset => {
64 if let Err(error) = normalize_legacy_yolo_settings() {
65 tracing::warn!(
66 "failed to normalize legacy YOLO settings; retrying next launch: {error:#}"
67 );
68 }
69 true
70 }
71 crate::config::ApprovalPolicyControl::RootConfig => {
72 let active_config_path = match crate::config::resolve_load_config_path(
73 config_path.clone(),
74 ) {
75 Ok(path) => path,
76 Err(error) => {
77 tracing::error!(
78 error = %error,
79 "could not resolve the active config path for legacy policy migration"
80 );
81 None
82 }
83 };
84 match crate::config_persistence::persist_unset_root_key(
85 active_config_path.as_deref(),
86 "approval_policy",
87 ) {
88 Ok(_) => {
89 if let Err(error) = normalize_legacy_yolo_settings() {
90 tracing::warn!(
91 "removed legacy approval_policy but could not normalize settings; retrying next launch: {error:#}"
92 );
93 }
94 true
95 }
96 Err(error) => {
97 tracing::warn!(
98 "could not migrate legacy YOLO approval policy; keeping the controlling policy: {error:#}"
99 );
100 false
101 }
102 }
103 }
104 source => {
105 tracing::warn!(
106 "legacy YOLO setting was not allowed to override {}",
107 source.label()
108 );
109 false
110 }
111 }
112 } else {
113 false
114 };
115 settings.apply_env_overrides();
116 let launch_visible =
117 settings.launch_screen && resume_session_id.is_none() && initial_input.is_none();
118 let launch = LaunchState::new(launch_visible, &workspace);
119
120 // If settings.toml exists on disk but couldn't be parsed (we fell back
121 // to defaults), surface a warning in the TUI so the user knows their
122 // file is broken instead of silently losing all settings.
123 let settings_parse_warning = crate::settings::Settings::path().ok().and_then(|p| {
124 if p.exists() {
125 std::fs::read_to_string(&p).ok().and_then(|raw| {
126 ::toml::from_str::<::toml::Value>(&raw)
127 .err()
128 .map(|e| format!("⚠ settings.toml is malformed — using defaults ({e})"))
129 })
130 } else {
131 None
132 }
133 });
134 let tui_prefs_warning = crate::settings::TuiPrefs::path().ok().and_then(|p| {
135 if p.exists() {
136 std::fs::read_to_string(&p).ok().and_then(|raw| {
137 ::toml::from_str::<::toml::Value>(&raw)
138 .err()
139 .map(|e| format!("⚠ tui.toml is malformed — using defaults ({e})"))
140 })
141 } else {
142 None
143 }
144 });
145
146 let mut provider = config.api_provider();
147
148 // A startup route saved explicitly from `/model` is a user choice and
149 // must win over a provider merely seeded in config.toml. A one-launch
150 // CLI/environment provider override still wins so scripts can pin
151 // their route without changing the user's next interactive launch.
152 let explicit_launch_provider = crate::config::explicit_launch_provider_override().is_some();
153 let mut provider_identity_record = config
154 .active_provider_identity(provider)
155 .unwrap_or_else(|_| {
156 let key = config.provider_identity_for(provider);
157 let exact_id = (!(provider == ApiProvider::Custom
158 && config.uses_legacy_literal_custom_route()))
159 .then(|| key.clone());
160 crate::config::ProviderIdentity {
161 provider,
162 key,
163 exact_id,
164 }
165 });
166 if !explicit_launch_provider
167 && let Some(ref provider_str) = settings.default_provider
168 && let Ok(resolved) = config.resolve_provider_identity(provider_str)
169 {
170 provider = resolved.provider;
171 provider_identity_record = resolved;
172 }
173 let provider_identity = provider_identity_record.key;
174 let provider_exact_id = provider_identity_record.exact_id;
175 let mut effective_auth_config = config.clone();
176 effective_auth_config.provider = Some(provider_identity.clone());
177
178 // #5032: a stale `[providers.xai] oauth_credential_generation` pointer
179 // whose owned credential file is gone makes `credentials_valid` return
180 // false with no recovery, so the generic provider picker reopened on
181 // EVERY launch (the dogfood bricked state). Detect that specific
182 // corrupted state, best-effort clear the stale pointer from the
183 // persisted config, and surface a truthful xAI-specific message. The
184 // repair never blocks or aborts launch; after it the state is the
185 // normal "needs auth", not a bricked loop.
186 // #5032: an onboarded user whose active xAI OAuth credential is missing
187 // must be guided to re-authenticate THAT provider — not be re-run through
188 // the generic provider picker on every launch. Detect the missing-cred
189 // state (broader than a dangling pointer: it also covers a repaired
190 // pointer, an expired/revoked token, or a never-completed login), repair
191 // a stale pointer once, surface a truthful xAI message, and suppress the
192 // picker-recovery path below.
193 let xai_oauth_needs_reauth = provider == ApiProvider::Xai
194 && effective_auth_config
195 .provider_config_for(ApiProvider::Xai)
196 .and_then(|entry| entry.auth_mode.as_deref())
197 .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
198 && !crate::xai_oauth::credentials_present(&effective_auth_config);
199 let xai_dangling_repair_message = if xai_oauth_needs_reauth {
200 if crate::xai_oauth::owned_generation_is_dangling(&effective_auth_config) {
201 match crate::xai_oauth::clear_dangling_xai_oauth_generation(config_path.as_deref())
202 {
203 Ok(()) => {
204 // Keep the in-memory route consistent with the repaired
205 // persisted file so the running app never reaches for
206 // the missing generation.
207 effective_auth_config
208 .provider_config_for_mut(ApiProvider::Xai)
209 .oauth_credential_generation = None;
210 }
211 Err(error) => {
212 tracing::warn!(
213 target: "codewhale::xai_oauth",
214 error = %error,
215 "could not clear the dangling xAI OAuth generation pointer; continuing launch"
216 );
217 }
218 }
219 }
220 Some(
221 "⚠ xAI OAuth credentials are missing. Re-authenticate with \
222 `codewhale auth xai-device` or the in-app login, or switch providers."
223 .to_string(),
224 )
225 } else {
226 None
227 };
228 let model_ids_passthrough = effective_auth_config.model_ids_pass_through();
229 let provider_chain = provider
230 .kind()
231 .map(|kind| ProviderChain::new(kind, &config.fallback_providers))
232 .filter(|chain| chain.providers().len() > 1);
233
234 // Snapshot per-provider readiness for the fallback chain (#2574). Uses
235 // the same `has_api_key_for` helper the provider picker uses, so hosted
236 // providers require a key and self-hosted ones (Ollama/vLLM/SGLang) are
237 // reported ready without one. Empty when there is no fallback chain.
238 let provider_readiness = provider_chain
239 .as_ref()
240 .map(|chain| {
241 chain
242 .providers()
243 .iter()
244 .map(|kind| {
245 let provider = ApiProvider::from_kind(*kind);
246 (provider, has_api_key_for(config, provider))
247 })
248 .collect()
249 })
250 .unwrap_or_default();
251
252 // Check if the effective provider has an API key. This must happen
253 // after settings.default_provider is applied; otherwise a saved
254 // third-party provider can be pushed back into DeepSeek onboarding.
255 let needs_api_key = !has_api_key(&effective_auth_config);
256 let api_key_env_only =
257 crate::config::active_provider_uses_env_only_api_key(&effective_auth_config);
258 let was_onboarded = crate::tui::onboarding::is_onboarded();
259 let settings_auto_compact = settings.auto_compact;
260 let auto_compact_user_configured = Settings::auto_compact_explicitly_configured();
261 let auto_compact_threshold_percent = settings.auto_compact_threshold_percent;
262 let calm_mode = settings.calm_mode;
263 let low_motion = settings.low_motion;
264 let constrained_frame_rate = settings.constrained_frame_rate;
265 let fancy_animations = settings.fancy_animations;
266 let ocean_treatment = crate::tui::ocean::OceanTreatment::parse(&settings.ocean_treatment);
267 let focus_texture =
268 crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture)
269 .unwrap_or_default();
270 let work_surface_placement =
271 crate::tui::work_surface::WorkSurfacePlacement::parse(&settings.work_surface_placement);
272 let work_surface_top_height = settings.work_surface_top_height;
273 let work_surface_side_width = settings.work_surface_side_width;
274 let synchronized_output_enabled = settings.synchronized_output_enabled();
275 let status_indicator = settings.status_indicator.clone();
276 let show_thinking = settings.show_thinking;
277 let thinking_highlight = settings.thinking_highlight;
278 let thinking_default_expanded = settings.thinking_default_expanded;
279 let show_tool_details = settings.show_tool_details;
280 let inline_diff_mode = InlineDiffMode::parse(&settings.inline_diffs);
281 let ui_locale = resolve_locale(&settings.locale);
282 let cost_currency = match (settings.cost_currency.as_str(), ui_locale.tag()) {
283 ("usd", "zh-Hans") => CostCurrency::Cny,
284 _ => CostCurrency::from_setting(&settings.cost_currency).unwrap_or(CostCurrency::Usd),
285 };
286 let composer_density = ComposerDensity::from_setting(&settings.composer_density);
287 let composer_border = settings.composer_border;
288 let composer_vim_enabled = settings
289 .composer_vim_mode
290 .trim()
291 .eq_ignore_ascii_case("vim");
292 let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
293 let max_input_history = settings.max_input_history;
294 let use_paste_burst_detection = settings.paste_burst_detection;
295 // Resolve the named theme from settings; unknown values were already
296 // normalised to "system" in Settings::load. The background_color
297 // setting still overlays on top.
298 let background_color_override = settings
299 .background_color
300 .as_deref()
301 .and_then(palette::parse_hex_rgb_color);
302 let background_setting = background_color_override.and_then(palette::hex_rgb_string);
303 let resolved_theme =
304 palette::resolve_theme_setting(&settings.theme, background_setting.as_deref());
305 let theme_warning = resolved_theme.as_ref().err().map(|error| {
306 format!(
307 "⚠ configured theme '{}' could not be loaded — using System ({error})",
308 settings.theme
309 )
310 });
311 let (_, theme_id, ui_theme) = resolved_theme.unwrap_or_else(|_| {
312 let id = palette::ThemeId::System;
313 let mut theme = id.ui_theme();
314 if let Some(background) = background_color_override {
315 theme = theme.with_background_color(background);
316 }
317 (id.name().to_string(), id, theme)
318 });
319 let provider_models = settings.provider_models.clone().unwrap_or_default();
320 // `provider_models` remembers the last `/model` pick per provider. It
321 // is a convenience default, not an override: when this launch named a
322 // model explicitly (`--model`, forwarded as `CODEWHALE_MODEL`), that
323 // request wins. Before this fix the memory won unconditionally, so
324 // `codewhale --provider moonshot --model kimi-k3` silently kept running
325 // the remembered `kimi-k2.7-code` while `doctor` reported `kimi-k3`.
326 let model = if crate::config::explicit_launch_model_override().is_some() {
327 model
328 } else {
329 let configured = model;
330 provider_models
331 .get(&provider_identity)
332 .cloned()
333 .or_else(|| {
334 // default_model is a DeepSeek-centric setting; other providers
335 // get their model from config.toml / env (e.g. OPENAI_MODEL).
336 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
337 settings.default_model.clone()
338 } else {
339 None
340 }
341 })
342 // The remembered pick may be a catalog spelling of the model
343 // the config file already names. Case-sensitive self-hosted
344 // endpoints reject the wrong spelling, so config.toml wins a
345 // case-only disagreement (the selection itself is unchanged).
346 .map(|remembered| {
347 crate::config::prefer_configured_model_spelling(&configured, remembered)
348 })
349 .unwrap_or(configured)
350 };
351 let auto_model = model.trim().eq_ignore_ascii_case("auto");
352 let mut enabled_provider_models = settings.enabled_models.clone().unwrap_or_default();
353 for (saved_provider, saved_model) in &provider_models {
354 push_enabled_provider_model(&mut enabled_provider_models, saved_provider, saved_model);
355 }
356 push_enabled_provider_model(&mut enabled_provider_models, &provider_identity, &model);
357 let active_context_window_override = config.context_window_for_provider_config(provider);
358 let configured_route_base_url = effective_auth_config.deepseek_base_url();
359 let (active_route_limits, active_route_base_url, active_context_window_source) =
360 if auto_model {
361 (
362 active_context_window_override.map(|window| RouteLimits {
363 context_tokens: Some(u64::from(window)),
364 ..RouteLimits::default()
365 }),
366 configured_route_base_url,
367 if active_context_window_override.is_some() {
368 crate::route_runtime::ContextWindowSource::Configured
369 } else {
370 crate::route_runtime::ContextWindowSource::Fallback
371 },
372 )
373 } else {
374 let saved_provider_model = config
375 .provider_config_for(provider)
376 .and_then(|provider| provider.model.as_deref());
377 crate::route_runtime::resolve_route_candidate_with_context_metadata(
378 provider,
379 Some(&model),
380 saved_provider_model,
381 Some(configured_route_base_url.clone()),
382 active_context_window_override,
383 None,
384 )
385 .map(|resolution| {
386 (
387 crate::route_budget::known_route_limits(resolution.candidate.limits()),
388 resolution.candidate.endpoint().base_url.clone(),
389 resolution.context_window.source,
390 )
391 })
392 .unwrap_or((
393 None,
394 configured_route_base_url,
395 crate::route_runtime::ContextWindowSource::Fallback,
396 ))
397 };
398 let reasoning_effort_explicit =
399 settings.reasoning_effort.is_some() || config.reasoning_effort_is_explicit();
400 let configured_reasoning_effort = settings
401 .reasoning_effort
402 .as_deref()
403 .or_else(|| config.reasoning_effort());
404 let reasoning_effort_preference = configured_reasoning_effort
405 .filter(|_| reasoning_effort_explicit)
406 .map(ReasoningEffort::from_setting);
407 let threshold_model = if auto_model {
408 DEFAULT_TEXT_MODEL
409 } else {
410 model.as_str()
411 };
412 let compact_threshold = crate::route_budget::compaction_threshold_for_route_at_percent(
413 provider,
414 threshold_model,
415 active_route_limits,
416 auto_compact_threshold_percent,
417 );
418 let auto_compact = if auto_compact_user_configured {
419 settings_auto_compact
420 } else {
421 crate::route_budget::auto_compact_default_for_route(
422 provider,
423 threshold_model,
424 active_route_limits,
425 )
426 };
427 let mut reasoning_effort = if auto_model && !reasoning_effort_explicit {
428 // A retired fixed-model alias can infer a compatibility effort in
429 // Config. That is route metadata, not an explicit user preference,
430 // so it must not silently constrain unresolved auto routing.
431 ReasoningEffort::Auto
432 } else {
433 configured_reasoning_effort.map_or_else(
434 || {
435 if auto_model {
436 ReasoningEffort::Auto
437 } else {
438 ReasoningEffort::default()
439 }
440 },
441 |setting| {
442 if auto_model {
443 ReasoningEffort::from_setting(setting)
444 } else {
445 ReasoningEffort::from_setting_for_provider(setting, provider)
446 }
447 },
448 )
449 };
450 if !auto_model
451 && !reasoning_effort_explicit
452 && let Some(effort) = crate::config::legacy_deepseek_alias_effort_for_route(
453 provider,
454 &effective_auth_config.deepseek_base_url(),
455 &model,
456 )
457 {
458 reasoning_effort = ReasoningEffort::from_setting_for_provider(effort, provider);
459 }
460 if !auto_model
461 && crate::config::is_exact_direct_moonshot_k3_route(
462 provider,
463 &active_route_base_url,
464 &model,
465 )
466 {
467 // Keep the visible/effective tier truthful on first launch too;
468 // direct K3 cannot honor a persisted `off` setting.
469 reasoning_effort =
470 reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model);
471 }
472
473 // Resolve the saved mode separately from the permission posture.
474 let preferred_mode = AppMode::from_setting(&settings.default_mode);
475 let yolo_compat = yolo || (preferred_mode == AppMode::Yolo && !start_in_agent_mode);
476 let initial_mode = if yolo_compat || start_in_agent_mode {
477 AppMode::Agent
478 } else {
479 preferred_mode
480 };
481 let needs_workspace_trust = !yolo_compat && crate::tui::onboarding::needs_trust(&workspace);
482 // Suppress the missing-key provider picker for the xAI-OAuth-missing-
483 // credential case: the user already chose xAI and just needs to
484 // re-authenticate it, not re-pick a provider every launch.
485 let (onboarding, onboarding_missing_key_recovery) = launch_onboarding_decision(
486 skip_onboarding,
487 was_onboarded,
488 needs_api_key,
489 needs_workspace_trust,
490 xai_oauth_needs_reauth,
491 );
492 let onboarding_workspace_trust_gate = onboarding_is_workspace_trust_gate(
493 skip_onboarding,
494 was_onboarded,
495 needs_api_key,
496 needs_workspace_trust,
497 );
498
499 // Durable Agent-era permission baseline (#3386). Plan/YOLO derive from
500 // and restore to this. Legacy Auto inputs parse to Agent; if an older
501 // caller still constructs `AppMode::Auto` directly, it projects through
502 // the Agent baseline instead of enabling a fourth runtime posture. When
503 // the user starts in YOLO the live shell flag is force-enabled below, so
504 // the baseline shell value is taken from the interactive default (the
505 // pre-mode Agent surface) rather than the YOLO-forced live mirror;
506 // otherwise it mirrors the resolved `allow_shell` option, which already
507 // carries that same interactive default. Using `interactive_allow_shell()`
508 // here keeps the Agent baseline identical regardless of launch mode, so
509 // a YOLO -> Agent downshift exposes shell (approval-gated) exactly as
510 // documented, while an explicit `allow_shell = false` still hides it.
511 // Trust is never part of the Agent baseline (it is YOLO-only authority).
512 // Approval mirrors the configured policy.
513 let explicit_approval_mode = (!legacy_yolo_full_access)
514 .then_some(config.approval_policy.as_deref())
515 .flatten()
516 .and_then(ApprovalMode::from_config_value);
517 let approval_policy_control = if legacy_yolo_full_access {
518 ApprovalPolicyControl::Unset
519 } else {
520 config.approval_policy_control(
521 config_path.as_deref(),
522 config_profile.as_deref(),
523 &workspace,
524 )
525 };
526 let approval_policy_locked = approval_policy_control != ApprovalPolicyControl::Unset;
527 let approval_policy_root_editable =
528 approval_policy_control == ApprovalPolicyControl::RootConfig;
529 let approval_policy_requirements_managed =
530 approval_policy_control == ApprovalPolicyControl::Requirements;
531 let saved_permission_posture = if approval_policy_locked {
532 None
533 } else {
534 settings
535 .permission_posture
536 .as_deref()
537 .and_then(ApprovalMode::from_config_value)
538 };
539 let configured_approval_mode = explicit_approval_mode
540 .or(saved_permission_posture)
541 .unwrap_or_default();
542 let configured_trust_mode = configured_approval_mode == ApprovalMode::Bypass;
543 let mode_prefs = ModeSessionPrefs {
544 agent_allow_shell: if yolo_compat || matches!(initial_mode, AppMode::Yolo) {
545 config.interactive_allow_shell()
546 } else {
547 allow_shell
548 },
549 agent_trust_mode: configured_trust_mode,
550 // The YOLO-compat launch elevates the *live* approval mirror to
551 // Bypass below; the durable Agent baseline keeps the configured
552 // policy so a YOLO -> Agent downshift restores it.
553 agent_approval_mode: configured_approval_mode,
554 };
555 let allow_shell = allow_shell || yolo_compat || matches!(initial_mode, AppMode::Yolo);
556 let shell_manager = new_shared_shell_manager(workspace.clone());
557
558 // Initialize hooks executor from config, merged with project-local
559 // `.codewhale/hooks.toml` (#3026).
560 let hooks_config =
561 crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace);
562 let hooks = HookExecutor::new(hooks_config, workspace.clone());
563
564 // Initialize plan state
565 let plan_state = new_shared_plan_state();
566 let todos = new_shared_todo_list();
567 let work_runtime =
568 crate::work_graph::new_shared_work_runtime(todos.clone(), plan_state.clone());
569
570 let skills_scan_codewhale_only = config.skills_config().scan_codewhale_only();
571 let skills_dir = resolve_skills_dir(&workspace, &global_skills_dir, config);
572 let cached_skills = Self::discover_cached_skills(
573 &workspace,
574 &skills_dir,
575 skills_scan_codewhale_only,
576 plugin_registry.as_ref(),
577 );
578
579 let input_history = crate::composer_history::load_history();
580 let mention_cwd = std::env::current_dir().ok();
581 let start_remote_control = matches!(initial_input, Some(InitialInput::RemoteControl));
582 let (initial_input_text, initial_input_cursor, auto_submit_initial_input) =
583 match initial_input {
584 // #451: pre-populate the composer when invoked via
585 // `deepseek pr <N>` (or any future caller that wants to
586 // drop the model into a session with context already
587 // typed). Cursor lands at the end so Enter sends as-is.
588 Some(InitialInput::Prefill(text)) if !text.is_empty() => {
589 let cursor = text.chars().count();
590 (text, cursor, false)
591 }
592 Some(InitialInput::Submit(text)) if !text.is_empty() => {
593 let cursor = text.chars().count();
594 (text, cursor, true)
595 }
596 Some(InitialInput::RemoteControl) => (String::new(), 0, false),
597 _ => (String::new(), 0, false),
598 };
599 let mcp_configured_count = crate::mcp::load_config_with_workspace_and_plugins(
600 &mcp_config_path,
601 &workspace,
602 plugin_registry.as_ref(),
603 )
604 .map(|cfg| cfg.servers.len())
605 .unwrap_or(0);
606 let mut hotbar_actions = HotbarActionRegistry::with_configured_routes(
607 config,
608 provider,
609 &model,
610 &provider_models,
611 );
612 // #2069: expose the already-discovered skills as bindable hotbar
613 // actions. Reuses the startup skill cache, so no extra filesystem I/O.
614 hotbar_actions.register_skills(&cached_skills);
615 let mut app = Self {
616 mode: initial_mode,
617 hotbar_actions,
618 composer: ComposerState {
619 input: initial_input_text,
620 cursor_position: initial_input_cursor,
621 kill_buffer: String::new(),
622 paste_burst: PasteBurst::default(),
623 pending_paste_reference: None,
624 oversized_paste_full_text: None,
625 input_history,
626 draft_history: VecDeque::new(),
627 clear_undo_buffer: None,
628 history_index: None,
629 history_navigation_draft: None,
630 composer_history_search: None,
631 selected_attachment_index: None,
632 slash_menu_selected: 0,
633 slash_menu_hidden: false,
634 mention_menu_selected: 0,
635 mention_menu_hidden: false,
636 mention_completion_cache: None,
637 mention_discovery: crate::tui::mention_completion::MentionDiscovery::default(),
638 mention_cwd,
639 vim_enabled: composer_vim_enabled,
640 vim_mode: VimMode::Normal,
641 vim_pending_d: false,
642 selection_anchor: None,
643 },
644 viewport: ViewportState::default(),
645 work_surface: {
646 let mut state = crate::tui::work_surface::WorkSurfaceState::with_layout(
647 work_surface_placement,
648 work_surface_top_height,
649 work_surface_side_width,
650 );
651 state.panel = crate::tui::work_surface::RailPanel::parse(&settings.rail_panel);
652 state
653 },
654 hunt: HuntState::default(),
655 session: SessionState::default(),
656 active_allowed_tools: None,
657 pausable: false,
658 pending_route_save: None,
659 paused: false,
660 paused_quarry: None,
661 history: Vec::new(),
662 history_version: 0,
663 history_revisions: Vec::new(),
664 tool_run_cache: ToolRunCache::default(),
665 next_history_revision: 1,
666 api_messages: Vec::new(),
667 context_token_cache: std::cell::RefCell::new(Default::default()),
668 remote_control: crate::remote_control::RemoteControlController::default(),
669 start_remote_control_on_launch: start_remote_control,
670 is_loading: false,
671 dispatch_completion_tx: None,
672 dispatch_in_flight: false,
673 last_enter_instant: None,
674 provider_wait_incident_logged: false,
675 prompt_suggestion: None,
676 prompt_suggestion_gen: std::sync::atomic::AtomicU64::new(0),
677 offline_mode: false,
678 turn_error_posted: false,
679 // Surface parse warnings so the user knows their config file is
680 // broken instead of silently losing all settings.
681 status_message: xai_dangling_repair_message
682 .or(settings_parse_warning)
683 .or(tui_prefs_warning)
684 .or(theme_warning),
685 status_toasts: VecDeque::new(),
686 update_available: None,
687 sticky_status: None,
688 last_status_message_seen: None,
689 model,
690 provider_models,
691 enabled_provider_models,
692 pinned_models: settings.pinned_models.clone(),
693 auto_model,
694 last_effective_model: None,
695 last_effective_provider: None,
696 last_effective_provider_identity: None,
697 last_auto_route_receipt: None,
698 pending_turn_route: None,
699 pending_auto_route_receipt: None,
700 active_turn: None,
701 api_provider: provider,
702 provider_identity,
703 provider_exact_id,
704 provider_chain,
705 provider_readiness,
706 provider_health: crate::provider_readiness::ProviderReadinessSnapshot::default(),
707 last_fallback_reason: None,
708 model_ids_passthrough,
709 active_route_limits,
710 active_route_base_url,
711 active_context_window_source,
712 active_context_window_override,
713 pending_provider_switch: None,
714 reasoning_effort,
715 reasoning_effort_preference,
716 last_effective_reasoning_effort: None,
717 workspace,
718 configured_sandbox_mode: config.sandbox_mode.clone(),
719 sandbox_backend: crate::sandbox::get_platform_sandbox_with_bwrap_preference(
720 config.prefer_bwrap.unwrap_or(false),
721 ),
722 // #4022: the worker thread is spawned lazily on first submit, so
723 // constructing an App never costs a thread.
724 lane_control: crate::lane_control::LaneControlQueue::new(),
725 plugin_registry,
726 config_path,
727 config_profile,
728 legacy_plugin_tools_dir: config
729 .tools
730 .as_ref()
731 .and_then(|tools| tools.plugin_dir.as_deref())
732 .map(PathBuf::from),
733 mcp_config_path: mcp_config_path.clone(),
734 skills_dir,
735 skills_scan_codewhale_only,
736 project_context_pack_enabled: config.project_context_pack_enabled(),
737 memory_path,
738 use_memory,
739 use_alt_screen,
740 use_mouse_capture,
741 use_bracketed_paste,
742 use_paste_burst_detection,
743 bracketed_paste_seen: false,
744 system_prompt: None,
745 auto_compact,
746 auto_compact_user_configured,
747 auto_compact_threshold_percent,
748 stopped_turn: false,
749 calm_mode,
750 low_motion,
751 constrained_frame_rate,
752 ocean_started_at: Instant::now(),
753 ambient_clock_ms: 0,
754 ambient_clock_sampled_at: None,
755 ambient_idle_since: None,
756 ocean_completion_started_at: None,
757 ocean_turn_history_start: 0,
758 ocean_receipt_settle_start: None,
759 fancy_animations,
760 ocean_treatment,
761 focus_texture,
762 launch,
763 pending_launch_action: None,
764 pending_hotbar_slot: None,
765 synchronized_output_enabled,
766 status_indicator,
767 show_thinking,
768 thinking_highlight,
769 thinking_default_expanded,
770 verbose_transcript: false,
771 show_tool_details,
772 inline_diff_mode,
773 ui_locale,
774 cost_currency,
775 billing_presentation: crate::route_billing::for_route(config, provider),
776 composer_density,
777 composer_border,
778 voice_enabled: false,
779 voice_send_enabled: false,
780 voice_control_enabled: false,
781 transcript_spacing,
782 sidebar_hover: SidebarHoverState::default(),
783 sidebar_hover_tooltip: None,
784 cached_work_summary: None,
785 model_picker_memory: None,
786 provider_picker_memory: None,
787 last_mouse_pos: None,
788 context_panel: settings.context_panel,
789 sessions_rail: settings.sessions_rail,
790 tool_collapse_threshold: 3,
791 expanded_tool_runs: HashSet::new(),
792 tool_collapse_mode: ToolCollapseMode::from_setting(&settings.tool_collapse_mode),
793 file_tree: None,
794 file_tree_visible: false,
795 compact_threshold,
796 max_input_history,
797 allow_shell,
798 verbosity: config.verbosity.clone(),
799 max_subagents,
800 stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(),
801 subagent_cache: Vec::new(),
802 subagent_terminal_seen_at: HashMap::new(),
803 agent_progress: HashMap::new(),
804 expanded_sidebar_agents: HashSet::new(),
805 agent_progress_meta: HashMap::new(),
806 subagent_card_index: HashMap::new(),
807 last_fanout_card_index: None,
808 pending_subagent_dispatch: None,
809 agent_activity_started_at: None,
810 agent_counter: 0,
811 agent_label_map: HashMap::new(),
812 last_agent_progress_redraw: None,
813 last_workflow_budget_redraw: None,
814 ui_theme,
815 background_color_override,
816 theme_id,
817 onboarding,
818 onboarding_needs_api_key: needs_api_key,
819 onboarding_provider: provider,
820 onboarding_workspace_trust_gate,
821 onboarding_missing_key_recovery,
822 onboarding_explore_offline: false,
823 onboarding_had_provider_step: !was_onboarded && needs_api_key,
824 onboarding_had_trust_step: !was_onboarded && needs_workspace_trust,
825 api_key_env_only,
826 hooks,
827 yolo: yolo_compat,
828 yolo_compat_notified: false,
829 startup_defaults: Default::default(),
830 keybinding_migration_notified: false,
831 mode_prefs,
832 approval_policy_locked,
833 approval_policy_root_editable,
834 approval_policy_requirements_managed,
835 clipboard: ClipboardHandler::new(),
836 approval_session_approved: HashSet::new(),
837 approval_session_denied: HashSet::new(),
838 approval_mode: if yolo_compat || matches!(initial_mode, AppMode::Yolo) {
839 ApprovalMode::Bypass
840 } else {
841 configured_approval_mode
842 },
843 view_stack: ViewStack::new(),
844 pending_user_input_prompt: None,
845 backtrack: crate::tui::backtrack::BacktrackState::new(),
846 current_session_id: None,
847 last_known_work_state: None,
848 current_session_metadata: None,
849 session_artifacts: Vec::new(),
850 trust_mode: yolo_compat || initial_mode == AppMode::Yolo || configured_trust_mode,
851 translation_enabled: false,
852 status_items: config
853 .tui
854 .as_ref()
855 .and_then(|tui| tui.status_items.clone())
856 .unwrap_or_else(crate::config::StatusItem::default_footer),
857 header_items: config
858 .tui
859 .as_ref()
860 .and_then(|tui| tui.header_items.clone())
861 .unwrap_or_else(crate::config::HeaderItem::default_header),
862 project_doc: None,
863 plan_state,
864 todos,
865 runtime_services: RuntimeToolServices {
866 shell_manager: Some(shell_manager),
867 work: Some(work_runtime),
868 ..RuntimeToolServices::default()
869 },
870 coordination_detail: None,
871 mcp_snapshot: None,
872 // Read the MCP config once at boot to know how many servers
873 // the user has declared. The footer chip uses this even when
874 // no live snapshot is available (#502). Cheap (just reads
875 // the JSON files); errors fall through to zero so a missing
876 // or malformed config simply hides the chip.
877 mcp_configured_count,
878 mcp_reload_required: false,
879 tool_log: Vec::new(),
880 active_skill: None,
881 active_skill_provenance: None,
882 cached_skills,
883 tool_cells: HashMap::new(),
884 tool_details_by_cell: HashMap::new(),
885 context_references_by_cell: HashMap::new(),
886 session_context_references: Vec::new(),
887 active_cell: None,
888 active_cell_revision: 0,
889 active_tool_details: HashMap::new(),
890 active_tool_entry_completed_at: HashMap::new(),
891 exploring_cell: None,
892 exploring_entries: HashMap::new(),
893 ignored_tool_calls: HashSet::new(),
894 last_exec_wait_command: None,
895 streaming_message_index: None,
896 streaming_source_receipt: None,
897 suppress_stream_events_until_turn_complete: false,
898 streaming_thinking_active_entry: None,
899 thinking_revision_last_bump_at: None,
900 streaming_state: StreamingState::new(),
901 streaming_output_token_estimate: 0,
902 reasoning_buffer: String::new(),
903 reasoning_header: None,
904 last_reasoning: None,
905 pending_tool_uses: Vec::new(),
906 queued_messages: VecDeque::new(),
907 queued_draft: None,
908 pending_steers: VecDeque::new(),
909 rejected_steers: VecDeque::new(),
910 submit_pending_steers_after_interrupt: false,
911 turn_started_at: None,
912 turn_last_activity_at: None,
913 cumulative_turn_duration: std::time::Duration::ZERO,
914 balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
915 draft_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
916 fleet_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
917 constitution_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
918 prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
919 balance_initiated: false,
920 last_balance_fetch: None,
921 runtime_turn_id: None,
922 runtime_turn_status: None,
923 turn_counter: 0,
924 dispatch_started_at: None,
925 workspace_context: None,
926 workspace_context_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
927 workspace_context_refreshed_at: None,
928 memory_size_hint: None,
929 task_panel: Vec::new(),
930 behavioral_tips: crate::tui::behavioral_tips::BehavioralTipState::default(),
931 decision_card: None,
932 workflow_panel: None,
933 session_started_at: chrono::Utc::now(),
934 needs_redraw: true,
935 force_next_full_repaint: false,
936 thinking_started_at: None,
937 is_compacting: false,
938 is_purging: false,
939 user_scrolled_during_stream: false,
940 last_send_at: None,
941 last_submitted_prompt: None,
942 auto_submit_initial_input,
943 quit_armed_until: None,
944 prefix_change_count: 0,
945 prefix_checks_total: 0,
946 prefix_stability_pct: None,
947 last_prefix_change_desc: None,
948 last_pinned_prefix_hash: None,
949 collapsed_cells: HashSet::new(),
950 folded_thinking: HashSet::new(),
951 collapsed_cell_map: Vec::new(),
952 edit_in_progress: false,
953 lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true),
954 lsp_repair: LspRepairState::default(),
955 composer_arrows_scroll: config
956 .tui
957 .as_ref()
958 .and_then(|tui| tui.composer_arrows_scroll)
959 .unwrap_or_else(|| default_composer_arrows_scroll(use_mouse_capture)),
960 mention_menu_limit: settings.mention_menu_limit,
961 mention_walk_depth: settings.mention_walk_depth,
962 mention_menu_behavior: settings.mention_menu_behavior.clone(),
963 workspace_follow_symlinks: settings.workspace_follow_symlinks,
964 session_title: None,
965 receipt_text: None,
966 receipt_started_at: None,
967 tool_evidence: Vec::new(),
968 };
969 if yolo_compat {
970 app.notify_yolo_compat_once();
971 }
972 app
973 }
974 }
975
976 /// Rewrite `settings.toml` with the legacy `default_mode = "yolo"` value
977 /// normalized away.
978 ///
979 /// The normalization happens during parsing, so an empty transaction *is* the
980 /// migration: load (which normalizes), then save. Doing it as its own
981 /// [`crate::settings::Settings::transact`] rather than saving the snapshot
982 /// `App::new` already loaded matters twice over. It cannot write back a stale
983 /// pre-image, and — because `App::new` runs on the same hot path as several
984 /// hundred tests — it keeps the transaction lock out of the common construction
985 /// path entirely, taking it only when a legacy file actually needs migrating.
986 fn normalize_legacy_yolo_settings() -> anyhow::Result<()> {
987 crate::settings::Settings::transact(|_normalized_on_load| Ok(()))
988 }
989
989 lines RUST