返回 CodeWhale
config_ui.rs
根目录 / crates / tui / src / config_ui.rs
1 use std::collections::BTreeMap;
2 #[cfg(feature = "web")]
3 use std::net::SocketAddr;
4 #[cfg(feature = "web")]
5 use std::time::Duration;
6
7 use anyhow::{Context, Result, bail};
8 use schemars::{JsonSchema, schema_for};
9 use serde::{Deserialize, Serialize};
10 use serde_json::Value;
11
12 use crate::commands;
13 use crate::config::{
14 Config, StatusItem, normalize_custom_model_id, normalize_model_name_for_provider,
15 validate_route,
16 };
17 use crate::localization::{normalize_configured_locale, resolve_locale};
18 use crate::settings::Settings;
19 use crate::tui::app::{App, AppMode, ComposerDensity, ReasoningEffort, TranscriptSpacing};
20 use crate::tui::approval::ApprovalMode;
21
22 #[cfg(feature = "web")]
23 use schemaui::web::session::{ServeOptions, WebSessionBuilder, bind_session};
24 #[cfg(feature = "tui")]
25 use schemaui::{FrontendOptions, SchemaUI, UiOptions};
26
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub enum ConfigUiMode {
29 Native,
30 Tui,
31 Web,
32 }
33
34 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
35 #[serde(rename_all = "snake_case")]
36 pub struct ConfigUiDocument {
37 pub runtime: RuntimeSection,
38 pub settings: SettingsSection,
39 pub config: ConfigSection,
40 }
41
42 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
43 #[serde(rename_all = "snake_case")]
44 pub struct RuntimeSection {
45 #[schemars(
46 title = "Active provider",
47 description = "Route fact only. Switch providers with /provider, then return here to tune this session."
48 )]
49 pub provider: String,
50 #[schemars(
51 title = "Current model",
52 description = "Model used by the active provider for this session. Save to apply the exact route choice."
53 )]
54 pub model: String,
55 pub approval_mode: ApprovalModeValue,
56 }
57
58 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
59 #[serde(rename_all = "snake_case")]
60 pub struct SettingsSection {
61 pub auto_compact: bool,
62 pub calm_mode: bool,
63 pub low_motion: bool,
64 pub fancy_animations: bool,
65 pub ocean_treatment: OceanTreatmentValue,
66 pub focus_texture: FocusTextureValue,
67 pub work_surface_placement: WorkSurfacePlacementValue,
68 #[schemars(range(min = 2, max = 16))]
69 pub work_surface_top_height: u16,
70 #[schemars(range(min = 26, max = 80))]
71 pub work_surface_side_width: u16,
72 pub paste_burst_detection: bool,
73 pub show_thinking: bool,
74 pub thinking_default_expanded: bool,
75 pub thinking_highlight: bool,
76 pub show_tool_details: bool,
77 pub inline_diffs: InlineDiffValue,
78 #[schemars(
79 title = "UI locale",
80 description = "Locale used by the TUI. zh-Hant is a partial pack; missing strings fall back to English."
81 )]
82 pub locale: UiLocale,
83 pub theme: UiThemeValue,
84 #[schemars(
85 title = "Custom theme name",
86 description = "Theme slug from the fixed Codewhale themes directory; used only when theme is custom."
87 )]
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub custom_theme_name: Option<String>,
90 #[schemars(
91 title = "Background color",
92 description = "Optional Blue Stage background override as #RRGGBB. Leave empty to keep the named theme."
93 )]
94 pub background_color: Option<String>,
95 pub bracketed_paste: bool,
96 pub composer_density: ComposerDensityValue,
97 pub composer_border: bool,
98 pub composer_vim_mode: ComposerVimModeValue,
99 #[schemars(range(min = 0))]
100 pub mention_menu_limit: usize,
101 pub mention_menu_behavior: MentionMenuBehaviorValue,
102 #[schemars(range(min = 0))]
103 pub mention_walk_depth: usize,
104 pub transcript_spacing: TranscriptSpacingValue,
105 pub status_indicator: StatusIndicatorValue,
106 pub synchronized_output: SynchronizedOutputValue,
107 pub default_mode: DefaultModeValue,
108 pub context_panel: bool,
109 #[schemars(range(min = 0))]
110 pub max_history: usize,
111 pub cost_currency: CostCurrencyValue,
112 #[schemars(
113 title = "Follow symlinks",
114 description = "Allow workspace discovery to cross symbolic links. Enable only when linked projects are part of this workspace."
115 )]
116 pub workspace_follow_symlinks: bool,
117 #[schemars(
118 title = "Provider model overrides",
119 description = "Durable model choices by provider identity. The active provider's entry follows Current model."
120 )]
121 pub provider_models: BTreeMap<String, String>,
122 pub default_model: Option<String>,
123 }
124
125 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
126 #[serde(rename_all = "snake_case")]
127 pub struct ConfigSection {
128 pub mcp_config_path: String,
129 pub reasoning_effort: ReasoningEffortValue,
130 #[schemars(title = "Status line items")]
131 pub status_items: Vec<StatusItemValue>,
132 }
133
134 #[derive(Debug, Clone)]
135 pub struct ConfigUiApplyOutcome {
136 pub changed: bool,
137 pub final_message: String,
138 pub requires_engine_sync: bool,
139 }
140
141 #[cfg(feature = "web")]
142 #[derive(Debug)]
143 pub struct WebConfigSession {
144 #[allow(dead_code)]
145 task: tokio::task::JoinHandle<()>,
146 pub receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
147 pub addr: SocketAddr,
148 }
149
150 #[cfg(not(feature = "web"))]
151 #[derive(Debug)]
152 pub struct WebConfigSession {
153 #[allow(dead_code)]
154 pub receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
155 }
156
157 #[cfg(test)]
158 impl WebConfigSession {
159 pub(crate) fn for_test(
160 receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
161 ) -> Self {
162 #[cfg(feature = "web")]
163 {
164 Self {
165 task: tokio::spawn(async {}),
166 receiver,
167 addr: SocketAddr::from(([127, 0, 0, 1], 0)),
168 }
169 }
170 #[cfg(not(feature = "web"))]
171 {
172 Self { receiver }
173 }
174 }
175 }
176
177 #[cfg_attr(not(feature = "web"), allow(dead_code))]
178 #[derive(Debug, Clone)]
179 pub enum WebConfigSessionEvent {
180 Draft(ConfigUiDocument),
181 Committed(ConfigUiDocument),
182 Failed(String),
183 }
184
185 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
186 #[serde(rename_all = "snake_case")]
187 pub enum ApprovalModeValue {
188 Auto,
189 Bypass,
190 Suggest,
191 Never,
192 }
193
194 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
195 pub enum UiLocale {
196 #[serde(rename = "auto")]
197 #[schemars(rename = "auto")]
198 Auto,
199 #[serde(rename = "en")]
200 #[schemars(rename = "en")]
201 En,
202 #[serde(rename = "ja")]
203 #[schemars(rename = "ja")]
204 Ja,
205 #[serde(rename = "zh-Hans")]
206 #[schemars(rename = "zh-Hans")]
207 ZhHans,
208 #[serde(rename = "zh-Hant")]
209 #[schemars(rename = "zh-Hant")]
210 ZhHant,
211 #[serde(rename = "pt-BR")]
212 #[schemars(rename = "pt-BR")]
213 PtBr,
214 #[serde(rename = "es-419")]
215 #[schemars(rename = "es-419")]
216 Es419,
217 #[serde(rename = "vi")]
218 #[schemars(rename = "vi")]
219 Vi,
220 #[serde(rename = "ko")]
221 #[schemars(rename = "ko")]
222 Ko,
223 #[serde(rename = "ca")]
224 #[schemars(rename = "ca")]
225 Ca,
226 #[serde(rename = "de")]
227 #[schemars(rename = "de")]
228 De,
229 #[serde(rename = "fr")]
230 #[schemars(rename = "fr")]
231 Fr,
232 #[serde(rename = "id")]
233 #[schemars(rename = "id")]
234 Id,
235 #[serde(rename = "hi")]
236 #[schemars(rename = "hi")]
237 Hi,
238 #[serde(rename = "ru")]
239 #[schemars(rename = "ru")]
240 Ru,
241 #[serde(rename = "uk")]
242 #[schemars(rename = "uk")]
243 Uk,
244 }
245
246 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
247 #[serde(rename_all = "kebab-case")]
248 pub enum UiThemeValue {
249 System,
250 Dark,
251 Light,
252 Grayscale,
253 CatppuccinMocha,
254 TokyoNight,
255 Dracula,
256 GruvboxDark,
257 Matrix,
258 Uwu,
259 Custom,
260 }
261
262 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
263 #[serde(rename_all = "snake_case")]
264 pub enum OceanTreatmentValue {
265 Ombre,
266 Flat,
267 }
268
269 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
270 #[serde(rename_all = "snake_case")]
271 pub enum FocusTextureValue {
272 Off,
273 Scrim,
274 Grain,
275 }
276
277 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
278 #[serde(rename_all = "snake_case")]
279 pub enum ComposerDensityValue {
280 Compact,
281 Comfortable,
282 Spacious,
283 }
284
285 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
286 #[serde(rename_all = "snake_case")]
287 pub enum ComposerVimModeValue {
288 Normal,
289 Vim,
290 }
291
292 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
293 #[serde(rename_all = "snake_case")]
294 pub enum MentionMenuBehaviorValue {
295 Fuzzy,
296 Browser,
297 }
298
299 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
300 #[serde(rename_all = "snake_case")]
301 pub enum TranscriptSpacingValue {
302 Compact,
303 Comfortable,
304 Spacious,
305 }
306
307 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
308 #[serde(rename_all = "snake_case")]
309 pub enum InlineDiffValue {
310 Full,
311 Summary,
312 Off,
313 }
314
315 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
316 #[serde(rename_all = "snake_case")]
317 pub enum WorkSurfacePlacementValue {
318 Top,
319 Left,
320 Right,
321 }
322
323 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
324 #[serde(rename_all = "snake_case")]
325 #[schemars(description = "Startup mode: Act (agent wire), Plan, or Operate")]
326 pub enum DefaultModeValue {
327 Agent,
328 Plan,
329 Operate,
330 }
331
332 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
333 #[serde(rename_all = "snake_case")]
334 pub enum CostCurrencyValue {
335 Usd,
336 Cny,
337 }
338
339 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
340 #[serde(rename_all = "snake_case")]
341 pub enum ReasoningEffortValue {
342 Off,
343 Low,
344 Medium,
345 High,
346 Auto,
347 Max,
348 }
349
350 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
351 #[serde(rename_all = "snake_case")]
352 pub enum StatusIndicatorValue {
353 Cw,
354 Whale,
355 Dots,
356 Off,
357 }
358
359 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
360 #[serde(rename_all = "snake_case")]
361 pub enum SynchronizedOutputValue {
362 Auto,
363 On,
364 Off,
365 }
366
367 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
368 #[serde(rename_all = "snake_case")]
369 pub enum StatusItemValue {
370 Mode,
371 Model,
372 Cost,
373 Status,
374 Agents,
375 ReasoningReplay,
376 PrefixStability,
377 Cache,
378 ContextPercent,
379 GitBranch,
380 LastToolElapsed,
381 RateLimit,
382 Tokens,
383 Balance,
384 }
385
386 pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> {
387 let raw = arg.unwrap_or("").trim();
388 // Bare `/config` opens the legacy native modal — it matches the rest
389 // of the codewhale-tui navy chrome out of the box. Power users can
390 // opt into the schemaui-driven editor with `/config tui`, or the
391 // browser surface with `/config web` (web feature only).
392 if raw.is_empty() || raw.eq_ignore_ascii_case("native") {
393 return Ok(ConfigUiMode::Native);
394 }
395 if raw.eq_ignore_ascii_case("tui") {
396 return Ok(ConfigUiMode::Tui);
397 }
398 if raw.eq_ignore_ascii_case("web") {
399 return Ok(ConfigUiMode::Web);
400 }
401 Err("Usage: /config [native|tui|web]".to_string())
402 }
403
404 pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
405 let settings = Settings::load_persisted().unwrap_or_default();
406 let reasoning_effort = app
407 .reasoning_effort_preference
408 .map(Into::into)
409 .or_else(|| {
410 config
411 .reasoning_effort()
412 .map(ReasoningEffortValue::from_setting)
413 })
414 .unwrap_or_else(|| app.reasoning_effort.into());
415 let default_model = settings.default_model.clone();
416 let status_items = app.status_items.iter().copied().map(Into::into).collect();
417 Ok(ConfigUiDocument {
418 runtime: RuntimeSection {
419 provider: app.provider_identity_for_persistence().to_string(),
420 model: app.model.clone(),
421 approval_mode: app.approval_mode.into(),
422 },
423 settings: SettingsSection {
424 auto_compact: app.auto_compact,
425 calm_mode: settings.calm_mode,
426 low_motion: settings.low_motion,
427 fancy_animations: settings.fancy_animations,
428 ocean_treatment: settings.ocean_treatment.as_str().into(),
429 focus_texture: settings.focus_texture.as_str().into(),
430 work_surface_placement: settings.work_surface_placement.as_str().into(),
431 work_surface_top_height: settings.work_surface_top_height,
432 work_surface_side_width: settings.work_surface_side_width,
433 paste_burst_detection: settings.paste_burst_detection,
434 show_thinking: settings.show_thinking,
435 thinking_default_expanded: settings.thinking_default_expanded,
436 thinking_highlight: settings.thinking_highlight,
437 show_tool_details: settings.show_tool_details,
438 inline_diffs: settings.inline_diffs.as_str().into(),
439 locale: UiLocale::from_setting(&settings.locale)?,
440 theme: UiThemeValue::from_setting(&settings.theme)?,
441 custom_theme_name: crate::palette::normalize_user_theme_selector(&settings.theme)
442 .map_err(anyhow::Error::msg)?
443 .map(|selector| {
444 selector
445 .trim_start_matches(crate::palette::USER_THEME_PREFIX)
446 .to_string()
447 }),
448 background_color: settings.background_color.clone(),
449 bracketed_paste: settings.bracketed_paste,
450 composer_density: settings.composer_density.as_str().into(),
451 composer_border: settings.composer_border,
452 composer_vim_mode: settings.composer_vim_mode.as_str().into(),
453 mention_menu_limit: settings.mention_menu_limit,
454 mention_menu_behavior: settings.mention_menu_behavior.as_str().into(),
455 mention_walk_depth: settings.mention_walk_depth,
456 transcript_spacing: settings.transcript_spacing.as_str().into(),
457 status_indicator: settings.status_indicator.as_str().into(),
458 synchronized_output: settings.synchronized_output.as_str().into(),
459 default_mode: settings.default_mode.as_str().into(),
460 context_panel: settings.context_panel,
461 max_history: settings.max_input_history,
462 cost_currency: CostCurrencyValue::from_setting(&settings.cost_currency)?,
463 workspace_follow_symlinks: settings.workspace_follow_symlinks,
464 provider_models: settings
465 .provider_models
466 .clone()
467 .unwrap_or_default()
468 .into_iter()
469 .collect(),
470 default_model,
471 },
472 config: ConfigSection {
473 mcp_config_path: app.mcp_config_path.display().to_string(),
474 reasoning_effort,
475 status_items,
476 },
477 })
478 }
479
480 pub fn build_schema() -> Value {
481 let mut schema = serde_json::to_value(schema_for!(ConfigUiDocument)).expect("config ui schema");
482 schema["title"] = Value::String("Codewhale Config".to_string());
483 schema["description"] = Value::String(
484 "Tune live runtime choices and durable TUI defaults. Provider switching stays in /provider."
485 .to_string(),
486 );
487 // Provider switching is asynchronous and owns client preflight, so this
488 // editor shows the route identity only as validation context. `/provider`
489 // remains the sole provider-switch surface.
490 schema["$defs"]["RuntimeSection"]["properties"]["provider"]["readOnly"] = Value::Bool(true);
491 schema
492 }
493
494 #[cfg(feature = "tui")]
495 pub fn run_tui_editor(app: &App, config: &Config) -> Result<ConfigUiDocument> {
496 let document = build_document(app, config)?;
497 let value = SchemaUI::new(serde_json::to_value(document.clone())?)
498 .with_schema(build_schema())
499 .with_title("Codewhale Config")
500 .with_description("Review the live route, then save the settings you want to keep.")
501 .run(FrontendOptions::Tui(
502 UiOptions::default()
503 .with_confirm_exit(true)
504 .with_bool_labels("On", "Off")
505 .with_integer_step(1)
506 .with_integer_fast_step(5)
507 .with_help(true),
508 ))?;
509 parse_document(value)
510 }
511
512 #[cfg(feature = "web")]
513 pub async fn start_web_editor(app: &App, config: &Config) -> Result<WebConfigSession> {
514 let initial = serde_json::to_value(build_document(app, config)?)?;
515 let session = WebSessionBuilder::new(build_schema())
516 .with_initial_data(initial)
517 .with_title("Codewhale Config")
518 .with_description(
519 "Save updates this browser draft. Exit returns the reviewed changes to the TUI.",
520 )
521 .build()?;
522 let bound = bind_session(session, ServeOptions::default()).await?;
523 let addr = bound.local_addr();
524 let url = format!("http://{addr}");
525 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
526 let app_snapshot = build_document(app, config)?;
527 let task = tokio::spawn(async move {
528 let poll_tx = tx.clone();
529 let poll_url = format!("{url}/api/session");
530 let poll_task = tokio::spawn(async move {
531 let client = crate::tls::reqwest_client();
532 let mut last: Option<ConfigUiDocument> = Some(app_snapshot);
533 loop {
534 tokio::time::sleep(Duration::from_millis(750)).await;
535 let response = match client.get(&poll_url).send().await {
536 Ok(response) => response,
537 Err(err) => {
538 let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!(
539 "config web poll failed: {err}"
540 )));
541 break;
542 }
543 };
544 if !response.status().is_success() {
545 continue;
546 }
547 let body: Value = match response.json().await {
548 Ok(body) => body,
549 Err(err) => {
550 let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!(
551 "config web decode failed: {err}"
552 )));
553 break;
554 }
555 };
556 let Some(data) = body.get("data") else {
557 continue;
558 };
559 let doc = match parse_document(data.clone()) {
560 Ok(doc) => doc,
561 Err(_) => continue,
562 };
563 if last.as_ref() == Some(&doc) {
564 continue;
565 }
566 let _ = poll_tx.send(WebConfigSessionEvent::Draft(doc.clone()));
567 last = Some(doc);
568 }
569 });
570
571 let result = bound.run().await;
572 poll_task.abort();
573 match result {
574 Ok(value) => match parse_document(value) {
575 Ok(doc) => {
576 let _ = tx.send(WebConfigSessionEvent::Committed(doc));
577 }
578 Err(err) => {
579 let _ = tx.send(WebConfigSessionEvent::Failed(format!(
580 "config web result decode failed: {err}"
581 )));
582 }
583 },
584 Err(err) => {
585 let _ = tx.send(WebConfigSessionEvent::Failed(format!(
586 "config web session failed: {err}"
587 )));
588 }
589 }
590 });
591 Ok(WebConfigSession {
592 task,
593 receiver: rx,
594 addr,
595 })
596 }
597
598 pub fn apply_document(
599 doc: ConfigUiDocument,
600 app: &mut App,
601 config: &mut Config,
602 persist: bool,
603 ) -> Result<ConfigUiApplyOutcome> {
604 validate_document(&doc, app, config)?;
605 let theme_setting = theme_setting_for_document(&doc)?;
606 let mut notes = Vec::new();
607 let previous_compaction = app.compaction_config();
608 let previous_reasoning_effort = app.reasoning_effort;
609
610 for (key, value) in [
611 ("model", doc.runtime.model.as_str()),
612 ("approval_mode", doc.runtime.approval_mode.as_setting()),
613 ("auto_compact", bool_str(doc.settings.auto_compact)),
614 ("calm_mode", bool_str(doc.settings.calm_mode)),
615 ("low_motion", bool_str(doc.settings.low_motion)),
616 ("fancy_animations", bool_str(doc.settings.fancy_animations)),
617 ("ocean_treatment", doc.settings.ocean_treatment.as_setting()),
618 ("focus_texture", doc.settings.focus_texture.as_setting()),
619 (
620 "work_surface_placement",
621 doc.settings.work_surface_placement.as_setting(),
622 ),
623 (
624 "work_surface_top_height",
625 &doc.settings.work_surface_top_height.to_string(),
626 ),
627 (
628 "work_surface_side_width",
629 &doc.settings.work_surface_side_width.to_string(),
630 ),
631 (
632 "paste_burst_detection",
633 bool_str(doc.settings.paste_burst_detection),
634 ),
635 ("show_thinking", bool_str(doc.settings.show_thinking)),
636 (
637 "thinking_default_expanded",
638 bool_str(doc.settings.thinking_default_expanded),
639 ),
640 (
641 "thinking_highlight",
642 bool_str(doc.settings.thinking_highlight),
643 ),
644 (
645 "show_tool_details",
646 bool_str(doc.settings.show_tool_details),
647 ),
648 ("inline_diffs", doc.settings.inline_diffs.as_setting()),
649 ("locale", doc.settings.locale.as_setting()),
650 ("theme", theme_setting.as_str()),
651 (
652 "background_color",
653 doc.settings
654 .background_color
655 .as_deref()
656 .unwrap_or("default"),
657 ),
658 ("bracketed_paste", bool_str(doc.settings.bracketed_paste)),
659 (
660 "composer_density",
661 doc.settings.composer_density.as_setting(),
662 ),
663 ("composer_border", bool_str(doc.settings.composer_border)),
664 (
665 "composer_vim_mode",
666 doc.settings.composer_vim_mode.as_setting(),
667 ),
668 (
669 "mention_menu_limit",
670 &doc.settings.mention_menu_limit.to_string(),
671 ),
672 (
673 "mention_menu_behavior",
674 doc.settings.mention_menu_behavior.as_setting(),
675 ),
676 (
677 "mention_walk_depth",
678 &doc.settings.mention_walk_depth.to_string(),
679 ),
680 (
681 "transcript_spacing",
682 doc.settings.transcript_spacing.as_setting(),
683 ),
684 (
685 "status_indicator",
686 doc.settings.status_indicator.as_setting(),
687 ),
688 (
689 "synchronized_output",
690 doc.settings.synchronized_output.as_setting(),
691 ),
692 ("default_mode", doc.settings.default_mode.as_setting()),
693 ("context_panel", bool_str(doc.settings.context_panel)),
694 ("max_history", &doc.settings.max_history.to_string()),
695 ("cost_currency", doc.settings.cost_currency.as_setting()),
696 (
697 "workspace_follow_symlinks",
698 bool_str(doc.settings.workspace_follow_symlinks),
699 ),
700 ("mcp_config_path", doc.config.mcp_config_path.as_str()),
701 ] {
702 let result = commands::set_config_value(app, key, value, persist);
703 if result.is_error {
704 bail!(
705 "{}",
706 result
707 .message
708 .unwrap_or_else(|| "config update failed".to_string())
709 );
710 }
711 if let Some(message) = result.message {
712 notes.push(message);
713 }
714 }
715
716 // default_model is only applied when persisting (it controls the model
717 // for future sessions). Processing it in the main loop would overwrite
718 // the runtime model the user just chose when persist=false (#346-fix).
719 if persist {
720 let default_model_val = doc.settings.default_model.as_deref().unwrap_or("default");
721 let result = commands::set_config_value(app, "default_model", default_model_val, true);
722 if result.is_error {
723 bail!(
724 "{}",
725 result
726 .message
727 .unwrap_or_else(|| "default_model update failed".to_string())
728 );
729 }
730 if let Some(message) = result.message {
731 notes.push(message);
732 }
733
734 // `/model` and the schema-driven config editor share one durable
735 // provider-scoped model map. `set_config_value("model", ...)` owns the
736 // live App mutation, while this block persists that selection without
737 // rewriting the DeepSeek-only global fallback (#3227).
738 let mut provider_models = doc
739 .settings
740 .provider_models
741 .iter()
742 .map(|(provider, model)| (provider.clone(), model.clone()))
743 .collect::<std::collections::HashMap<_, _>>();
744 provider_models.insert(
745 app.provider_identity_for_persistence().to_string(),
746 app.model_selection_for_persistence(),
747 );
748 Settings::transact(|settings| {
749 settings.provider_models = (!provider_models.is_empty()).then_some(provider_models);
750 Ok(())
751 })?;
752 notes.push(format!(
753 "{} model saved for {}",
754 app.model_display_label(),
755 app.provider_identity_for_persistence()
756 ));
757 }
758
759 apply_reasoning_effort(app, config, doc.config.reasoning_effort, persist)?;
760 let requires_engine_sync = app.compaction_config() != previous_compaction
761 || app.reasoning_effort != previous_reasoning_effort;
762
763 let new_status_items = parse_status_items(&doc.config.status_items);
764 if app.status_items != new_status_items {
765 app.status_items = new_status_items.clone();
766 app.needs_redraw = true;
767 if persist {
768 let path = crate::config_persistence::persist_status_items(&new_status_items)?;
769 notes.push(format!("status_items saved to {}", path.display()));
770 } else {
771 notes.push("status_items updated for this session".to_string());
772 }
773 }
774
775 if persist {
776 reload_runtime_config(app, config)?;
777 notes.extend(config_reload_notes(app, config));
778 }
779 let changed = !notes.is_empty();
780 let final_message = if notes.is_empty() {
781 if persist {
782 "Config unchanged".to_string()
783 } else {
784 "Runtime config unchanged".to_string()
785 }
786 } else {
787 notes.last().cloned().unwrap_or_default()
788 };
789 Ok(ConfigUiApplyOutcome {
790 changed,
791 final_message,
792 requires_engine_sync,
793 })
794 }
795
796 pub fn parse_document(value: Value) -> Result<ConfigUiDocument> {
797 serde_json::from_value(value).context("failed to decode config ui document")
798 }
799
800 #[cfg(feature = "web")]
801 pub fn open_browser(url: &str) -> Result<()> {
802 crate::utils::open_url(url)
803 }
804
805 fn validate_document(doc: &ConfigUiDocument, app: &App, config: &Config) -> Result<()> {
806 let document_identity = config
807 .resolve_provider_identity(&doc.runtime.provider)
808 .map_err(anyhow::Error::msg)?;
809 if document_identity.provider != app.api_provider
810 || document_identity.key != app.provider_identity_for_persistence()
811 {
812 bail!(
813 "provider changed from '{}' to '{}' while editing; switch providers with /provider, then reopen /config",
814 app.provider_identity_for_persistence(),
815 doc.runtime.provider
816 );
817 }
818 validate_and_normalize_model(config, app.api_provider, &doc.runtime.model)?;
819 for (provider_id, model) in &doc.settings.provider_models {
820 let identity = config
821 .resolve_provider_identity(provider_id)
822 .map_err(anyhow::Error::msg)?;
823 validate_and_normalize_model(config, identity.provider, model)
824 .map_err(|err| anyhow::anyhow!("invalid provider_models.{provider_id} value: {err}"))?;
825 }
826 if doc.config.mcp_config_path.trim().is_empty() {
827 bail!("mcp_config_path cannot be empty");
828 }
829 let _ = theme_setting_for_document(doc)?;
830 Ok(())
831 }
832
833 fn theme_setting_for_document(doc: &ConfigUiDocument) -> Result<String> {
834 let setting = if doc.settings.theme == UiThemeValue::Custom {
835 let name = doc
836 .settings
837 .custom_theme_name
838 .as_deref()
839 .map(str::trim)
840 .filter(|name| !name.is_empty())
841 .ok_or_else(|| anyhow::anyhow!("custom theme requires custom_theme_name"))?;
842 format!("{}{}", crate::palette::USER_THEME_PREFIX, name)
843 } else {
844 doc.settings.theme.as_setting().to_string()
845 };
846 crate::palette::resolve_theme_setting(&setting, None)
847 .map(|(normalized, _, _)| normalized)
848 .map_err(anyhow::Error::msg)
849 }
850
851 /// Validate and normalize a model against the provider's *effective* route.
852 ///
853 /// A custom DeepSeek base URL owns its own model namespace, so non-DeepSeek ids
854 /// must be accepted here the same way request-time
855 /// `wire_model_for_provider_route` preserves them. The previous provider-only
856 /// gate rejected those saves even though the live session could use them.
857 fn validate_and_normalize_model(
858 config: &Config,
859 provider: crate::config::ApiProvider,
860 model: &str,
861 ) -> Result<String> {
862 let model = model.trim();
863 if model.is_empty() {
864 bail!(
865 "invalid model '{model}' for provider '{}'",
866 provider.as_str()
867 );
868 }
869 if model.eq_ignore_ascii_case("auto") {
870 // Still run the provider gate so empty/unknown providers cannot sneak
871 // an `auto` through without the same checks as `/model auto`.
872 validate_route(provider, model).map_err(anyhow::Error::msg)?;
873 return Ok("auto".to_string());
874 }
875
876 // OpenCode Go is protocol-bound (Chat Completions only) even when its base
877 // URL is overridden — never open the custom-endpoint passthrough for it.
878 if provider == crate::config::ApiProvider::OpencodeGo {
879 validate_route(provider, model).map_err(anyhow::Error::msg)?;
880 return normalize_model_name_for_provider(provider, model).ok_or_else(|| {
881 anyhow::anyhow!(
882 "invalid model '{model}' for provider '{}'",
883 provider.as_str()
884 )
885 });
886 }
887
888 // Custom / self-hosted endpoints own their model id namespace. Match the
889 // request path (`wire_model_for_provider_route`) so saving `/config` on a
890 // custom deepseek gateway does not reject a non-DeepSeek wire id the
891 // session is already running.
892 if config.provider_uses_custom_endpoint(provider) {
893 return normalize_custom_model_id(model).ok_or_else(|| {
894 anyhow::anyhow!(
895 "invalid model '{model}' for provider '{}'",
896 provider.as_str()
897 )
898 });
899 }
900
901 validate_route(provider, model).map_err(anyhow::Error::msg)?;
902 normalize_model_name_for_provider(provider, model).ok_or_else(|| {
903 anyhow::anyhow!(
904 "invalid model '{model}' for provider '{}'",
905 provider.as_str()
906 )
907 })
908 }
909
910 fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> {
911 let reloaded = Config::load(app.config_path.clone(), app.config_profile.as_deref())?;
912 *config = reloaded.clone();
913 // Match App startup precedence: an explicit config provider wins, while a
914 // config with no provider keeps the saved TUI provider. Reloading an
915 // unrelated setting must never pair a Z.ai model with DeepSeek merely
916 // because `Config::default()` is DeepSeek.
917 let settings = Settings::load_persisted().unwrap_or_default();
918 let identity = if reloaded
919 .provider
920 .as_deref()
921 .is_some_and(|provider| !provider.trim().is_empty())
922 {
923 reloaded.active_provider_identity(reloaded.api_provider())
924 } else if let Some(provider) = settings.default_provider.as_deref() {
925 reloaded.resolve_provider_identity(provider)
926 } else {
927 reloaded.active_provider_identity(reloaded.api_provider())
928 }
929 .map_err(anyhow::Error::msg)?;
930 app.set_provider_identity_record(identity);
931 let requested = reloaded
932 .reasoning_effort()
933 .map(ReasoningEffort::from_setting)
934 .or(app.reasoning_effort_preference)
935 .unwrap_or(app.reasoning_effort);
936 if reloaded.reasoning_effort().is_some() {
937 app.reasoning_effort_preference = Some(requested);
938 }
939 app.reasoning_effort = if app.auto_model {
940 requested
941 } else {
942 requested.normalize_for_provider(app.api_provider)
943 };
944 app.invalidate_route_receipts_for_reasoning_change();
945 app.update_model_compaction_budget();
946 app.mcp_config_path = reloaded.mcp_config_path();
947 app.skills_dir = reloaded.skills_dir();
948 app.ui_locale = resolve_locale(&settings.locale);
949 Ok(())
950 }
951
952 fn config_reload_notes(app: &App, config: &Config) -> Vec<String> {
953 let mut notes = Vec::new();
954 notes.push("Config saved and reloaded".to_string());
955 if app.mcp_reload_required {
956 notes.push(format!(
957 "MCP tool pool still needs `/mcp reload` after {}",
958 config.mcp_config_path().display()
959 ));
960 }
961 notes
962 }
963
964 fn apply_reasoning_effort(
965 app: &mut App,
966 config: &mut Config,
967 value: ReasoningEffortValue,
968 persist: bool,
969 ) -> Result<()> {
970 let requested = ReasoningEffort::from(value);
971 let effective = if app.auto_model {
972 requested
973 } else {
974 requested.normalize_for_provider(app.api_provider)
975 };
976 app.reasoning_effort = effective;
977 app.reasoning_effort_preference = Some(requested);
978 app.invalidate_route_receipts_for_reasoning_change();
979 app.update_model_compaction_budget();
980 if persist {
981 crate::config_persistence::persist_root_string_key(
982 app.config_path.as_deref(),
983 "reasoning_effort",
984 requested.as_setting(),
985 )?;
986 // App startup gives settings.toml precedence over config.toml. Keep
987 // the schema-driven TUI/web editor aligned with the picker so an older
988 // saved startup value cannot silently undo this persisted choice on
989 // the next launch.
990 app.startup_defaults.apply_blocking(
991 crate::tui::startup_defaults::StartupDefaults::reasoning_effort(requested.as_setting()),
992 )?;
993 }
994 config.reasoning_effort = Some(requested.as_setting().to_string());
995 Ok(())
996 }
997
998 fn parse_status_items(items: &[StatusItemValue]) -> Vec<StatusItem> {
999 items.iter().copied().map(Into::into).collect()
1000 }
1001
1002 impl ApprovalModeValue {
1003 fn as_setting(self) -> &'static str {
1004 match self {
1005 Self::Auto => "auto",
1006 Self::Bypass => "bypass",
1007 Self::Suggest => "suggest",
1008 Self::Never => "never",
1009 }
1010 }
1011 }
1012
1013 impl UiLocale {
1014 fn as_setting(self) -> &'static str {
1015 match self {
1016 Self::Auto => "auto",
1017 Self::En => "en",
1018 Self::Ja => "ja",
1019 Self::ZhHans => "zh-Hans",
1020 Self::ZhHant => "zh-Hant",
1021 Self::PtBr => "pt-BR",
1022 Self::Es419 => "es-419",
1023 Self::Vi => "vi",
1024 Self::Ko => "ko",
1025 Self::Ca => "ca",
1026 Self::De => "de",
1027 Self::Fr => "fr",
1028 Self::Id => "id",
1029 Self::Hi => "hi",
1030 Self::Ru => "ru",
1031 Self::Uk => "uk",
1032 }
1033 }
1034
1035 fn from_setting(value: &str) -> Result<Self> {
1036 match normalize_configured_locale(value) {
1037 Some("auto") => Ok(Self::Auto),
1038 Some("en") => Ok(Self::En),
1039 Some("ja") => Ok(Self::Ja),
1040 Some("zh-Hans") => Ok(Self::ZhHans),
1041 Some("zh-Hant") => Ok(Self::ZhHant),
1042 Some("pt-BR") => Ok(Self::PtBr),
1043 Some("es-419") => Ok(Self::Es419),
1044 Some("vi") => Ok(Self::Vi),
1045 Some("ko") => Ok(Self::Ko),
1046 Some("ca") => Ok(Self::Ca),
1047 Some("de") => Ok(Self::De),
1048 Some("fr") => Ok(Self::Fr),
1049 Some("id") => Ok(Self::Id),
1050 Some("hi") => Ok(Self::Hi),
1051 Some("ru") => Ok(Self::Ru),
1052 Some("uk") => Ok(Self::Uk),
1053 Some(other) => bail!("unsupported locale '{other}'"),
1054 None => bail!("invalid locale '{value}'"),
1055 }
1056 }
1057 }
1058
1059 impl UiThemeValue {
1060 fn as_setting(self) -> &'static str {
1061 match self {
1062 Self::System => "system",
1063 Self::Dark => "dark",
1064 Self::Light => "light",
1065 Self::Grayscale => "grayscale",
1066 Self::CatppuccinMocha => "catppuccin-mocha",
1067 Self::TokyoNight => "tokyo-night",
1068 Self::Dracula => "dracula",
1069 Self::GruvboxDark => "gruvbox-dark",
1070 Self::Matrix => "matrix",
1071 Self::Uwu => "uwu",
1072 Self::Custom => "custom",
1073 }
1074 }
1075
1076 fn from_setting(value: &str) -> Result<Self> {
1077 if crate::palette::normalize_user_theme_selector(value)
1078 .map_err(anyhow::Error::msg)?
1079 .is_some()
1080 {
1081 return Ok(Self::Custom);
1082 }
1083 match crate::palette::normalize_theme_name(value) {
1084 Some("system") => Ok(Self::System),
1085 Some("dark") => Ok(Self::Dark),
1086 Some("light") => Ok(Self::Light),
1087 Some("grayscale") => Ok(Self::Grayscale),
1088 Some("catppuccin-mocha") => Ok(Self::CatppuccinMocha),
1089 Some("tokyo-night") => Ok(Self::TokyoNight),
1090 Some("dracula") => Ok(Self::Dracula),
1091 Some("gruvbox-dark") => Ok(Self::GruvboxDark),
1092 Some("matrix") => Ok(Self::Matrix),
1093 Some("uwu") => Ok(Self::Uwu),
1094 Some(other) => bail!("unsupported theme '{other}'"),
1095 None => bail!("invalid theme '{value}'"),
1096 }
1097 }
1098 }
1099
1100 impl OceanTreatmentValue {
1101 fn as_setting(self) -> &'static str {
1102 match self {
1103 Self::Ombre => "ombre",
1104 Self::Flat => "flat",
1105 }
1106 }
1107 }
1108
1109 impl From<&str> for OceanTreatmentValue {
1110 fn from(value: &str) -> Self {
1111 if value.trim().eq_ignore_ascii_case("flat") {
1112 Self::Flat
1113 } else {
1114 Self::Ombre
1115 }
1116 }
1117 }
1118
1119 impl FocusTextureValue {
1120 fn as_setting(self) -> &'static str {
1121 match self {
1122 Self::Off => "off",
1123 Self::Scrim => "scrim",
1124 Self::Grain => "grain",
1125 }
1126 }
1127 }
1128
1129 impl From<&str> for FocusTextureValue {
1130 fn from(value: &str) -> Self {
1131 match value.trim().to_ascii_lowercase().as_str() {
1132 "scrim" => Self::Scrim,
1133 "grain" => Self::Grain,
1134 _ => Self::Off,
1135 }
1136 }
1137 }
1138
1139 impl ComposerDensityValue {
1140 fn as_setting(self) -> &'static str {
1141 match self {
1142 Self::Compact => "compact",
1143 Self::Comfortable => "comfortable",
1144 Self::Spacious => "spacious",
1145 }
1146 }
1147 }
1148
1149 impl ComposerVimModeValue {
1150 fn as_setting(self) -> &'static str {
1151 match self {
1152 Self::Normal => "normal",
1153 Self::Vim => "vim",
1154 }
1155 }
1156 }
1157
1158 impl From<&str> for ComposerVimModeValue {
1159 fn from(value: &str) -> Self {
1160 match value.trim().to_ascii_lowercase().as_str() {
1161 "vim" => Self::Vim,
1162 _ => Self::Normal,
1163 }
1164 }
1165 }
1166
1167 impl MentionMenuBehaviorValue {
1168 fn as_setting(self) -> &'static str {
1169 match self {
1170 Self::Fuzzy => "fuzzy",
1171 Self::Browser => "browser",
1172 }
1173 }
1174 }
1175
1176 impl From<&str> for MentionMenuBehaviorValue {
1177 fn from(value: &str) -> Self {
1178 match value.trim().to_ascii_lowercase().as_str() {
1179 "browser" => Self::Browser,
1180 _ => Self::Fuzzy,
1181 }
1182 }
1183 }
1184
1185 impl TranscriptSpacingValue {
1186 fn as_setting(self) -> &'static str {
1187 match self {
1188 Self::Compact => "compact",
1189 Self::Comfortable => "comfortable",
1190 Self::Spacious => "spacious",
1191 }
1192 }
1193 }
1194
1195 impl InlineDiffValue {
1196 fn as_setting(self) -> &'static str {
1197 match self {
1198 Self::Full => "full",
1199 Self::Summary => "summary",
1200 Self::Off => "off",
1201 }
1202 }
1203 }
1204
1205 impl From<&str> for InlineDiffValue {
1206 fn from(value: &str) -> Self {
1207 match value.trim().to_ascii_lowercase().as_str() {
1208 "summary" => Self::Summary,
1209 "off" => Self::Off,
1210 _ => Self::Full,
1211 }
1212 }
1213 }
1214
1215 impl WorkSurfacePlacementValue {
1216 fn as_setting(self) -> &'static str {
1217 match self {
1218 Self::Top => "top",
1219 Self::Left => "left",
1220 Self::Right => "right",
1221 }
1222 }
1223 }
1224
1225 impl From<&str> for WorkSurfacePlacementValue {
1226 fn from(value: &str) -> Self {
1227 match value.trim().to_ascii_lowercase().as_str() {
1228 "left" => Self::Left,
1229 "right" => Self::Right,
1230 _ => Self::Top,
1231 }
1232 }
1233 }
1234
1235 impl DefaultModeValue {
1236 fn as_setting(self) -> &'static str {
1237 match self {
1238 Self::Agent => "agent",
1239 Self::Plan => "plan",
1240 Self::Operate => "operate",
1241 }
1242 }
1243
1244 /// User-facing label in config UI (wire values stay agent/plan/operate).
1245 #[allow(dead_code)] // reserved for config UI option rendering / chrome
1246 pub fn label(self) -> &'static str {
1247 match self {
1248 Self::Agent => "Act",
1249 Self::Plan => "Plan",
1250 Self::Operate => "Operate",
1251 }
1252 }
1253
1254 #[allow(dead_code)]
1255 fn description(self) -> &'static str {
1256 match self {
1257 Self::Agent => "Do the work in this session.",
1258 Self::Plan => "Design first; implement after you approve.",
1259 Self::Operate => "Dispatch workers; keep the parent free for steers.",
1260 }
1261 }
1262 }
1263
1264 impl CostCurrencyValue {
1265 fn from_setting(value: &str) -> Result<Self> {
1266 match value.trim().to_ascii_lowercase().as_str() {
1267 "usd" => Ok(Self::Usd),
1268 "cny" | "rmb" | "yuan" => Ok(Self::Cny),
1269 other => {
1270 anyhow::bail!("Invalid cost_currency '{other}': expected usd, cny, rmb, or yuan")
1271 }
1272 }
1273 }
1274
1275 fn as_setting(self) -> &'static str {
1276 match self {
1277 Self::Usd => "usd",
1278 Self::Cny => "cny",
1279 }
1280 }
1281 }
1282
1283 impl From<ApprovalMode> for ApprovalModeValue {
1284 fn from(value: ApprovalMode) -> Self {
1285 match value {
1286 ApprovalMode::Auto => Self::Auto,
1287 ApprovalMode::Bypass => Self::Bypass,
1288 ApprovalMode::Suggest => Self::Suggest,
1289 ApprovalMode::Never => Self::Never,
1290 }
1291 }
1292 }
1293
1294 impl From<ReasoningEffort> for ReasoningEffortValue {
1295 fn from(value: ReasoningEffort) -> Self {
1296 match value {
1297 ReasoningEffort::Off => Self::Off,
1298 ReasoningEffort::Low => Self::Low,
1299 ReasoningEffort::Medium => Self::Medium,
1300 ReasoningEffort::High => Self::High,
1301 ReasoningEffort::Auto => Self::Auto,
1302 ReasoningEffort::Minimal => Self::Low,
1303 ReasoningEffort::XHigh => Self::Max,
1304 ReasoningEffort::Ultra => Self::Max,
1305 ReasoningEffort::Max => Self::Max,
1306 }
1307 }
1308 }
1309
1310 impl ReasoningEffortValue {
1311 fn from_setting(value: &str) -> Self {
1312 match ReasoningEffort::from_setting(value) {
1313 ReasoningEffort::Off => Self::Off,
1314 ReasoningEffort::Low => Self::Low,
1315 ReasoningEffort::Medium => Self::Medium,
1316 ReasoningEffort::High => Self::High,
1317 ReasoningEffort::Auto => Self::Auto,
1318 ReasoningEffort::Minimal => Self::Low,
1319 ReasoningEffort::XHigh => Self::Max,
1320 ReasoningEffort::Ultra => Self::Max,
1321 ReasoningEffort::Max => Self::Max,
1322 }
1323 }
1324 }
1325
1326 impl From<ReasoningEffortValue> for ReasoningEffort {
1327 fn from(value: ReasoningEffortValue) -> Self {
1328 match value {
1329 ReasoningEffortValue::Off => Self::Off,
1330 ReasoningEffortValue::Low => Self::Low,
1331 ReasoningEffortValue::Medium => Self::Medium,
1332 ReasoningEffortValue::High => Self::High,
1333 ReasoningEffortValue::Auto => Self::Auto,
1334 ReasoningEffortValue::Max => Self::Max,
1335 }
1336 }
1337 }
1338
1339 impl From<&str> for ComposerDensityValue {
1340 fn from(value: &str) -> Self {
1341 match ComposerDensity::from_setting(value) {
1342 ComposerDensity::Compact => Self::Compact,
1343 ComposerDensity::Comfortable => Self::Comfortable,
1344 ComposerDensity::Spacious => Self::Spacious,
1345 }
1346 }
1347 }
1348
1349 impl From<&str> for TranscriptSpacingValue {
1350 fn from(value: &str) -> Self {
1351 match TranscriptSpacing::from_setting(value) {
1352 TranscriptSpacing::Compact => Self::Compact,
1353 TranscriptSpacing::Comfortable => Self::Comfortable,
1354 TranscriptSpacing::Spacious => Self::Spacious,
1355 }
1356 }
1357 }
1358
1359 impl From<&str> for DefaultModeValue {
1360 fn from(value: &str) -> Self {
1361 match value.trim().to_ascii_lowercase().as_str() {
1362 "operate" | "operation" | "ops" => Self::Operate,
1363 // yolo was a mode+permission bundle; startup mode becomes Act and
1364 // permission posture is migrated separately on load.
1365 other => match AppMode::from_setting(other) {
1366 AppMode::Plan => Self::Plan,
1367 AppMode::Operate => Self::Operate,
1368 AppMode::Agent | AppMode::Yolo | AppMode::Auto => Self::Agent,
1369 },
1370 }
1371 }
1372 }
1373
1374 impl StatusIndicatorValue {
1375 fn as_setting(self) -> &'static str {
1376 match self {
1377 Self::Cw => "cw",
1378 Self::Whale => "whale",
1379 Self::Dots => "dots",
1380 Self::Off => "off",
1381 }
1382 }
1383 }
1384
1385 impl SynchronizedOutputValue {
1386 fn as_setting(self) -> &'static str {
1387 match self {
1388 Self::Auto => "auto",
1389 Self::On => "on",
1390 Self::Off => "off",
1391 }
1392 }
1393 }
1394
1395 impl From<&str> for SynchronizedOutputValue {
1396 fn from(value: &str) -> Self {
1397 match value.trim().to_ascii_lowercase().as_str() {
1398 "on" | "true" | "yes" | "1" | "enabled" => Self::On,
1399 "off" | "false" | "no" | "0" | "disabled" => Self::Off,
1400 _ => Self::Auto,
1401 }
1402 }
1403 }
1404
1405 impl From<&str> for StatusIndicatorValue {
1406 fn from(value: &str) -> Self {
1407 // Permissive aliases mirror `Settings::normalize_status_indicator`,
1408 // so a TOML file with `status_indicator = "🐳"` or `"none"`
1409 // resolves to the canonical enum variant.
1410 match value.trim().to_ascii_lowercase().as_str() {
1411 "cw" | "mark" | "text" => Self::Cw,
1412 "dots" | "dot" => Self::Dots,
1413 "off" | "none" | "hidden" | "false" => Self::Off,
1414 "whale" | "🐳" | "🐋" => Self::Whale,
1415 _ => Self::Cw,
1416 }
1417 }
1418 }
1419
1420 impl From<StatusItem> for StatusItemValue {
1421 fn from(value: StatusItem) -> Self {
1422 match value {
1423 StatusItem::Mode => Self::Mode,
1424 StatusItem::Model => Self::Model,
1425 StatusItem::Cost => Self::Cost,
1426 StatusItem::Status => Self::Status,
1427 StatusItem::Agents => Self::Agents,
1428 StatusItem::ReasoningReplay => Self::ReasoningReplay,
1429 StatusItem::PrefixStability => Self::PrefixStability,
1430 StatusItem::Cache => Self::Cache,
1431 StatusItem::ContextPercent => Self::ContextPercent,
1432 StatusItem::GitBranch => Self::GitBranch,
1433 StatusItem::LastToolElapsed => Self::LastToolElapsed,
1434 StatusItem::RateLimit => Self::RateLimit,
1435 StatusItem::Tokens => Self::Tokens,
1436 StatusItem::Balance => Self::Balance,
1437 }
1438 }
1439 }
1440
1441 impl From<StatusItemValue> for StatusItem {
1442 fn from(value: StatusItemValue) -> Self {
1443 match value {
1444 StatusItemValue::Mode => Self::Mode,
1445 StatusItemValue::Model => Self::Model,
1446 StatusItemValue::Cost => Self::Cost,
1447 StatusItemValue::Status => Self::Status,
1448 StatusItemValue::Agents => Self::Agents,
1449 StatusItemValue::ReasoningReplay => Self::ReasoningReplay,
1450 StatusItemValue::PrefixStability => Self::PrefixStability,
1451 StatusItemValue::Cache => Self::Cache,
1452 StatusItemValue::ContextPercent => Self::ContextPercent,
1453 StatusItemValue::GitBranch => Self::GitBranch,
1454 StatusItemValue::LastToolElapsed => Self::LastToolElapsed,
1455 StatusItemValue::RateLimit => Self::RateLimit,
1456 StatusItemValue::Tokens => Self::Tokens,
1457 StatusItemValue::Balance => Self::Balance,
1458 }
1459 }
1460 }
1461
1462 fn bool_str(value: bool) -> &'static str {
1463 if value { "true" } else { "false" }
1464 }
1465
1466 #[cfg(test)]
1467 mod tests {
1468 use super::*;
1469 use crate::config::{ApiProvider, Config};
1470 use crate::test_support::{EnvVarGuard, lock_test_env};
1471 use crate::tui::app::{App, TuiOptions};
1472 use std::fs;
1473 use std::path::PathBuf;
1474 use std::time::{SystemTime, UNIX_EPOCH};
1475
1476 fn app() -> App {
1477 let options = TuiOptions {
1478 use_alt_screen: false,
1479 // Keep this fixture independent from the developer's saved
1480 // `default_mode` setting.
1481 start_in_agent_mode: true,
1482 ..crate::test_support::test_tui_options(PathBuf::from("."))
1483 };
1484 let mut app = App::new(options, &Config::default());
1485 // App::new merges developer-local settings, which can include a saved
1486 // provider/model from the interactive TUI. Keep these config UI tests
1487 // pinned to DeepSeek defaults so they only exercise document apply
1488 // semantics.
1489 app.model = "deepseek-v4-pro".to_string();
1490 app.auto_model = false;
1491 app.api_provider = ApiProvider::Deepseek;
1492 app.model_ids_passthrough = false;
1493 app.active_route_limits = None;
1494 app.reasoning_effort_preference = None;
1495 app.update_model_compaction_budget();
1496 app
1497 }
1498
1499 #[test]
1500 fn build_document_reflects_app_state() {
1501 let mut app = app();
1502 app.auto_model = false;
1503 app.model = "deepseek-v4-pro".to_string();
1504 app.reasoning_effort = ReasoningEffort::Max;
1505 let config = Config::default();
1506 let doc = build_document(&app, &config).expect("document");
1507 assert_eq!(doc.runtime.model, app.model);
1508 // The document must mirror the live app posture. The developer's saved
1509 // permission posture may legitimately be Bypass; this test must not
1510 // rewrite that product setting into an assumed Suggest default.
1511 assert_eq!(doc.runtime.approval_mode, app.approval_mode.into());
1512 assert_eq!(doc.config.reasoning_effort, ReasoningEffortValue::Max);
1513 }
1514
1515 #[test]
1516 fn build_document_uses_raw_reasoning_preference() {
1517 let mut app = app();
1518 app.api_provider = ApiProvider::OpenaiCodex;
1519 app.reasoning_effort = ReasoningEffort::Low;
1520 app.reasoning_effort_preference = Some(ReasoningEffort::Off);
1521
1522 let doc = build_document(&app, &Config::default()).expect("document");
1523
1524 assert_eq!(doc.config.reasoning_effort, ReasoningEffortValue::Off);
1525 }
1526
1527 #[test]
1528 fn config_ui_reasoning_keeps_raw_preference_for_fixed_route() {
1529 let mut app = app();
1530 app.api_provider = ApiProvider::OpenaiCodex;
1531 app.reasoning_effort = ReasoningEffort::High;
1532 app.reasoning_effort_preference = None;
1533 let mut config = Config::default();
1534
1535 apply_reasoning_effort(&mut app, &mut config, ReasoningEffortValue::Off, false)
1536 .expect("apply reasoning");
1537
1538 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
1539 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
1540 assert_eq!(config.reasoning_effort.as_deref(), Some("off"));
1541 }
1542
1543 #[test]
1544 fn persisted_config_ui_reasoning_updates_the_startup_precedence_layer() {
1545 let _lock = lock_test_env();
1546 let temp_root = tempfile::tempdir().expect("isolated Codewhale home");
1547 let codewhale_home = temp_root.path().join(".codewhale");
1548 fs::create_dir_all(&codewhale_home).expect("settings dir");
1549 fs::write(
1550 codewhale_home.join("settings.toml"),
1551 "default_model = \"auto\"\nreasoning_effort = \"max\"\n",
1552 )
1553 .expect("seed settings");
1554 let config_path = temp_root.path().join("config.toml");
1555 fs::write(&config_path, "reasoning_effort = \"max\"\n").expect("seed config");
1556 let _home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1557
1558 let mut app = app();
1559 app.config_path = Some(config_path.clone());
1560 let mut config = Config::load(Some(config_path.clone()), None).expect("load config");
1561
1562 apply_reasoning_effort(&mut app, &mut config, ReasoningEffortValue::Low, true)
1563 .expect("persist reasoning");
1564
1565 assert_eq!(
1566 Settings::load_persisted()
1567 .expect("reload settings")
1568 .reasoning_effort
1569 .as_deref(),
1570 Some("low")
1571 );
1572 let persisted_config =
1573 Config::load(Some(config_path), None).expect("reload persisted config");
1574 let restored = App::new(
1575 TuiOptions {
1576 use_alt_screen: false,
1577 start_in_agent_mode: true,
1578 ..crate::test_support::test_tui_options(PathBuf::from("."))
1579 },
1580 &persisted_config,
1581 );
1582 assert!(restored.auto_model);
1583 assert_eq!(restored.reasoning_effort, ReasoningEffort::Low);
1584 assert_eq!(
1585 restored.reasoning_effort_preference,
1586 Some(ReasoningEffort::Low)
1587 );
1588 }
1589
1590 #[test]
1591 fn legacy_startup_mode_values_project_to_agent_in_config_ui() {
1592 assert_eq!(DefaultModeValue::from("agent"), DefaultModeValue::Agent);
1593 assert_eq!(DefaultModeValue::from("operate"), DefaultModeValue::Operate);
1594 assert_eq!(DefaultModeValue::from("yolo"), DefaultModeValue::Agent);
1595 assert_eq!(DefaultModeValue::from("plan"), DefaultModeValue::Plan);
1596 assert_eq!(DefaultModeValue::Agent.as_setting(), "agent");
1597 assert_eq!(DefaultModeValue::Plan.as_setting(), "plan");
1598 assert_eq!(DefaultModeValue::Operate.as_setting(), "operate");
1599 assert_eq!(DefaultModeValue::Agent.label(), "Act");
1600 assert_eq!(DefaultModeValue::Operate.label(), "Operate");
1601 }
1602
1603 #[test]
1604 fn build_document_reflects_cost_currency_from_settings() {
1605 let _lock = lock_test_env();
1606 let nanos = SystemTime::now()
1607 .duration_since(UNIX_EPOCH)
1608 .expect("clock")
1609 .as_nanos();
1610 let temp_root = std::env::temp_dir().join(format!(
1611 "codewhale-config-ui-cost-currency-{}-{}",
1612 std::process::id(),
1613 nanos
1614 ));
1615 fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir");
1616 let config_path = temp_root.join(".deepseek").join("config.toml");
1617 fs::write(&config_path, "").expect("seed config");
1618 fs::write(
1619 temp_root.join(".deepseek").join("settings.toml"),
1620 r#"
1621 cost_currency = "cny"
1622 "#,
1623 )
1624 .expect("seed settings");
1625
1626 let old_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH");
1627 // Safety: test-only environment mutation guarded by a module mutex.
1628 unsafe {
1629 std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
1630 }
1631
1632 let app = app();
1633 let config = Config::default();
1634 let doc = build_document(&app, &config).expect("document");
1635
1636 assert_eq!(doc.settings.cost_currency, CostCurrencyValue::Cny);
1637 // Safety: restore the guarded test-only environment mutation above.
1638 unsafe {
1639 if let Some(value) = old_config_path {
1640 std::env::set_var("DEEPSEEK_CONFIG_PATH", value);
1641 } else {
1642 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
1643 }
1644 }
1645 }
1646
1647 #[test]
1648 fn build_document_reflects_background_color_from_settings() {
1649 let _lock = lock_test_env();
1650 let nanos = SystemTime::now()
1651 .duration_since(UNIX_EPOCH)
1652 .expect("clock")
1653 .as_nanos();
1654 let temp_root = std::env::temp_dir().join(format!(
1655 "codewhale-config-ui-background-color-{}-{}",
1656 std::process::id(),
1657 nanos
1658 ));
1659 fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir");
1660 let config_path = temp_root.join(".deepseek").join("config.toml");
1661 fs::write(&config_path, "").expect("seed config");
1662 fs::write(
1663 temp_root.join(".deepseek").join("settings.toml"),
1664 r##"
1665 background_color = "#1A1B26"
1666 "##,
1667 )
1668 .expect("seed settings");
1669
1670 let old_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH");
1671 unsafe {
1672 std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
1673 }
1674
1675 let app = app();
1676 let config = Config::default();
1677 let doc = build_document(&app, &config).expect("document");
1678
1679 assert_eq!(doc.settings.background_color.as_deref(), Some("#1a1b26"));
1680 unsafe {
1681 if let Some(value) = old_config_path {
1682 std::env::set_var("DEEPSEEK_CONFIG_PATH", value);
1683 } else {
1684 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
1685 }
1686 }
1687 }
1688
1689 #[test]
1690 fn build_document_accepts_every_shipped_locale_from_settings() {
1691 let _lock = lock_test_env();
1692 let temp_root = tempfile::tempdir().expect("isolated Codewhale home");
1693 let codewhale_home = temp_root.path().join(".codewhale");
1694 fs::create_dir_all(&codewhale_home).expect("settings dir");
1695 let settings_path = codewhale_home.join("settings.toml");
1696 fs::write(&settings_path, "").expect("seed settings");
1697 let _home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1698
1699 let app = app();
1700 let config = Config::default();
1701 for tag in std::iter::once("auto").chain(
1702 crate::localization::Locale::shipped()
1703 .iter()
1704 .map(|locale| locale.tag()),
1705 ) {
1706 fs::write(&settings_path, format!("locale = \"{tag}\"\n"))
1707 .unwrap_or_else(|err| panic!("persist locale {tag}: {err}"));
1708 let doc = build_document(&app, &config)
1709 .unwrap_or_else(|err| panic!("build config document for {tag}: {err}"));
1710 assert_eq!(
1711 doc.settings.locale.as_setting(),
1712 tag,
1713 "typed config document must preserve persisted locale {tag}"
1714 );
1715 }
1716 }
1717
1718 #[test]
1719 fn custom_theme_round_trips_through_typed_config_document() {
1720 let _lock = lock_test_env();
1721 let temp_root = tempfile::tempdir().expect("isolated Codewhale home");
1722 let codewhale_home = temp_root.path().join(".codewhale");
1723 let themes_dir = codewhale_home.join("themes");
1724 fs::create_dir_all(&themes_dir).expect("themes dir");
1725 fs::write(
1726 themes_dir.join("ocean.json"),
1727 r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##,
1728 )
1729 .expect("custom theme");
1730 fs::write(
1731 codewhale_home.join("settings.toml"),
1732 r#"theme = "custom:ocean"
1733 "#,
1734 )
1735 .expect("settings");
1736 let _home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1737
1738 let mut app = app();
1739 let mut config = Config::default();
1740 let doc = build_document(&app, &config).expect("document");
1741 assert_eq!(doc.settings.theme, UiThemeValue::Custom);
1742 assert_eq!(doc.settings.custom_theme_name.as_deref(), Some("ocean"));
1743
1744 apply_document(doc, &mut app, &mut config, false).expect("apply custom theme");
1745 assert_eq!(
1746 app.ui_theme.accent_primary,
1747 ratatui::style::Color::Rgb(0x12, 0x34, 0x56)
1748 );
1749 }
1750
1751 #[test]
1752 fn schema_contains_typed_enums() {
1753 let schema = build_schema();
1754 assert_eq!(schema["title"], serde_json::json!("Codewhale Config"));
1755 assert!(
1756 schema["description"]
1757 .as_str()
1758 .is_some_and(|copy| copy.contains("Provider switching stays in /provider"))
1759 );
1760 assert_eq!(
1761 schema["$defs"]["RuntimeSection"]["properties"]["provider"]["readOnly"],
1762 serde_json::json!(true)
1763 );
1764 assert!(
1765 schema["$defs"]["SettingsSection"]["properties"]["background_color"]["description"]
1766 .as_str()
1767 .is_some_and(|copy| copy.contains("Blue Stage"))
1768 );
1769 assert!(
1770 schema["$defs"]["SettingsSection"]["properties"]["locale"]["description"]
1771 .as_str()
1772 .is_some_and(|copy| {
1773 copy.contains("zh-Hant")
1774 && copy.contains("partial")
1775 && copy.contains("fall back to English")
1776 })
1777 );
1778 let approval_mode = &schema["$defs"]["ApprovalModeValue"]["enum"];
1779 assert_eq!(
1780 approval_mode,
1781 &serde_json::json!(["auto", "bypass", "suggest", "never"])
1782 );
1783 let default_mode_def = &schema["$defs"]["DefaultModeValue"];
1784 let default_mode = default_mode_def
1785 .get("enum")
1786 .cloned()
1787 .or_else(|| {
1788 default_mode_def.get("oneOf").and_then(|ones| {
1789 let mut vals = Vec::new();
1790 for item in ones.as_array()? {
1791 if let Some(v) = item.get("const") {
1792 vals.push(v.clone());
1793 } else if let Some(arr) = item.get("enum").and_then(|e| e.as_array()) {
1794 vals.extend(arr.iter().cloned());
1795 }
1796 }
1797 Some(serde_json::Value::Array(vals))
1798 })
1799 })
1800 .unwrap_or(serde_json::Value::Null);
1801 assert_eq!(
1802 default_mode,
1803 serde_json::json!(["agent", "plan", "operate"]),
1804 "DefaultModeValue schema: {default_mode_def}"
1805 );
1806 let inline_diffs = &schema["$defs"]["InlineDiffValue"]["enum"];
1807 assert_eq!(inline_diffs, &serde_json::json!(["full", "summary", "off"]));
1808 let locale = &schema["$defs"]["UiLocale"]["enum"];
1809 let expected_locales = std::iter::once("auto")
1810 .chain(
1811 crate::localization::Locale::shipped()
1812 .iter()
1813 .map(|locale| locale.tag()),
1814 )
1815 .collect::<Vec<_>>();
1816 assert_eq!(
1817 locale,
1818 &serde_json::json!(expected_locales),
1819 "UiLocale schema must match Locale::shipped()"
1820 );
1821 let theme = &schema["$defs"]["UiThemeValue"]["enum"];
1822 assert_eq!(
1823 theme,
1824 &serde_json::json!([
1825 "system",
1826 "dark",
1827 "light",
1828 "grayscale",
1829 "catppuccin-mocha",
1830 "tokyo-night",
1831 "dracula",
1832 "gruvbox-dark",
1833 "matrix",
1834 "uwu",
1835 "custom"
1836 ])
1837 );
1838 }
1839
1840 #[test]
1841 fn ui_locale_round_trips_every_shipped_locale() {
1842 for locale in crate::localization::Locale::shipped() {
1843 let tag = locale.tag();
1844 let ui_locale = UiLocale::from_setting(tag)
1845 .unwrap_or_else(|err| panic!("UiLocale must accept shipped locale {tag}: {err}"));
1846 assert_eq!(
1847 ui_locale.as_setting(),
1848 tag,
1849 "UiLocale must preserve shipped locale {tag}"
1850 );
1851 let serialized = serde_json::to_value(ui_locale)
1852 .unwrap_or_else(|err| panic!("serialize UiLocale {tag}: {err}"));
1853 assert_eq!(serialized, serde_json::json!(tag));
1854 assert_eq!(
1855 serde_json::from_value::<UiLocale>(serialized)
1856 .unwrap_or_else(|err| panic!("deserialize UiLocale {tag}: {err}")),
1857 ui_locale
1858 );
1859 }
1860 }
1861
1862 #[test]
1863 fn parse_document_roundtrip() {
1864 let _lock = lock_test_env();
1865 let app = app();
1866 let config = Config::default();
1867 let doc = build_document(&app, &config).expect("document");
1868 let value = serde_json::to_value(doc.clone()).expect("json");
1869 let parsed = parse_document(value).expect("parsed");
1870 assert_eq!(parsed, doc);
1871 }
1872
1873 #[test]
1874 fn session_only_apply_keeps_runtime_overrides_and_skips_reload() {
1875 let _lock = lock_test_env();
1876 let nanos = SystemTime::now()
1877 .duration_since(UNIX_EPOCH)
1878 .expect("clock")
1879 .as_nanos();
1880 let temp_root = std::env::temp_dir().join(format!(
1881 "codewhale-config-ui-session-only-{}-{}",
1882 std::process::id(),
1883 nanos
1884 ));
1885 fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir");
1886 let config_path = temp_root.join(".deepseek").join("config.toml");
1887 fs::write(
1888 &config_path,
1889 r#"
1890 model = "deepseek-v4-pro"
1891 reasoning_effort = "max"
1892 mcp_config_path = "disk-mcp.json"
1893 "#,
1894 )
1895 .expect("seed config");
1896
1897 let mut app = app();
1898 app.config_path = Some(config_path.clone());
1899 app.model = "deepseek-v4-pro".to_string();
1900 app.mcp_config_path = PathBuf::from("disk-mcp.json");
1901 app.reasoning_effort = ReasoningEffort::Max;
1902 let mut config = Config::load(Some(config_path), None).expect("load config");
1903
1904 let mut doc = build_document(&app, &config).expect("document");
1905 doc.runtime.model = "deepseek-v4-flash".to_string();
1906 doc.config.reasoning_effort = ReasoningEffortValue::Low;
1907 doc.config.mcp_config_path = "session-mcp.json".to_string();
1908 doc.settings.cost_currency = CostCurrencyValue::Cny;
1909
1910 let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
1911
1912 assert!(outcome.changed);
1913 assert!(outcome.requires_engine_sync);
1914 assert_eq!(app.model, "deepseek-v4-flash");
1915 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
1916 assert_eq!(app.mcp_config_path, PathBuf::from("session-mcp.json"));
1917 assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
1918 assert_eq!(
1919 config.reasoning_effort.as_deref(),
1920 Some(ReasoningEffort::Low.as_setting())
1921 );
1922 assert_eq!(
1923 config.mcp_config_path.as_deref(),
1924 Some("disk-mcp.json"),
1925 "session-only apply must not reload persisted config back into runtime state"
1926 );
1927 }
1928
1929 #[test]
1930 fn zai_document_persists_provider_model_without_changing_deepseek_fallback() {
1931 let _lock = lock_test_env();
1932 let temp_root = tempfile::tempdir().expect("isolated config dir");
1933 let state_dir = temp_root.path().join(".deepseek");
1934 fs::create_dir_all(&state_dir).expect("state dir");
1935 let config_path = state_dir.join("config.toml");
1936 fs::write(
1937 &config_path,
1938 r#"default_text_model = "deepseek-v4-pro"
1939 mcp_config_path = "mcp.json"
1940
1941 [providers.zai]
1942 model = "GLM-5.2"
1943 "#,
1944 )
1945 .expect("seed config");
1946 fs::write(
1947 state_dir.join("settings.toml"),
1948 r#"default_provider = "zai"
1949 default_model = "deepseek-v4-pro"
1950
1951 [provider_models]
1952 zai = "GLM-5.2"
1953 "#,
1954 )
1955 .expect("seed settings");
1956
1957 let _config_path_guard = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path);
1958
1959 let mut config = Config::load(Some(config_path.clone()), None).expect("load config");
1960 let mut app = app();
1961 app.config_path = Some(config_path.clone());
1962 app.set_provider_identity_record(
1963 config
1964 .active_provider_identity(ApiProvider::Zai)
1965 .expect("Z.ai identity"),
1966 );
1967 app.set_model_selection("GLM-5.2".to_string());
1968
1969 let mut doc = build_document(&app, &config).expect("document");
1970 assert_eq!(doc.runtime.provider, "zai");
1971 assert_eq!(doc.runtime.model, "GLM-5.2");
1972 assert_eq!(
1973 doc.settings.provider_models.get("zai").map(String::as_str),
1974 Some("GLM-5.2")
1975 );
1976 doc.runtime.model = "glm-5-turbo".to_string();
1977
1978 let outcome = apply_document(doc, &mut app, &mut config, true).expect("apply Z.ai model");
1979 assert!(outcome.changed);
1980 assert!(outcome.requires_engine_sync);
1981 assert_eq!(app.api_provider, ApiProvider::Zai);
1982 assert_eq!(app.model, "GLM-5-Turbo");
1983
1984 let settings = Settings::load_persisted().expect("persisted settings");
1985 assert_eq!(
1986 settings
1987 .provider_models
1988 .as_ref()
1989 .and_then(|models| models.get("zai"))
1990 .map(String::as_str),
1991 Some("GLM-5-Turbo")
1992 );
1993 assert_eq!(settings.default_model.as_deref(), Some("deepseek-v4-pro"));
1994 let persisted = fs::read_to_string(&config_path).expect("persisted config");
1995 let persisted: toml::Value = toml::from_str(&persisted).expect("parse persisted config");
1996 assert_eq!(
1997 persisted["default_text_model"].as_str(),
1998 Some("deepseek-v4-pro")
1999 );
2000 }
2001
2002 #[test]
2003 fn zai_document_rejects_a_deepseek_model_before_mutating_app() {
2004 let mut config = Config {
2005 provider: Some("zai".to_string()),
2006 ..Config::default()
2007 };
2008 let mut app = app();
2009 app.set_provider_identity(ApiProvider::Zai, "zai");
2010 app.set_model_selection("GLM-5.2".to_string());
2011 let mut doc = build_document(&app, &config).expect("document");
2012 doc.settings.provider_models.clear();
2013 doc.runtime.model = "deepseek-v4-flash".to_string();
2014
2015 let error = apply_document(doc, &mut app, &mut config, false)
2016 .expect_err("foreign model must be rejected");
2017
2018 assert!(
2019 error
2020 .to_string()
2021 .contains("not compatible with provider 'zai'")
2022 );
2023 assert_eq!(app.api_provider, ApiProvider::Zai);
2024 assert_eq!(app.model, "GLM-5.2");
2025 }
2026
2027 #[test]
2028 fn custom_deepseek_endpoint_accepts_non_deepseek_model_on_save() {
2029 // Provider-only validation used to reject any non-DeepSeek id even when
2030 // the active deepseek route pointed at a custom OpenAI-compatible
2031 // gateway that owns its own model namespace.
2032 let mut config = Config {
2033 provider: Some("deepseek".to_string()),
2034 base_url: Some("https://tenant-gateway.example.test/v1".to_string()),
2035 default_text_model: Some("anthropic/private-model".to_string()),
2036 ..Config::default()
2037 };
2038 let mut app = app();
2039 app.set_provider_identity(ApiProvider::Deepseek, "deepseek");
2040 app.set_model_selection("anthropic/private-model".to_string());
2041 // Mirror a live custom-route session: the endpoint owns model ids.
2042 app.active_route_base_url = "https://tenant-gateway.example.test/v1".to_string();
2043 app.model_ids_passthrough = true;
2044 let mut doc = build_document(&app, &config).expect("document");
2045 doc.settings.provider_models.clear();
2046 doc.runtime.model = "org/custom-router-id".to_string();
2047
2048 let outcome = apply_document(doc, &mut app, &mut config, false)
2049 .expect("custom deepseek endpoint must accept non-DeepSeek wire ids");
2050
2051 assert!(outcome.changed);
2052 assert_eq!(app.model, "org/custom-router-id");
2053 }
2054
2055 #[test]
2056 fn official_deepseek_endpoint_still_rejects_non_deepseek_model_on_save() {
2057 let mut config = Config {
2058 provider: Some("deepseek".to_string()),
2059 ..Config::default()
2060 };
2061 let mut app = app();
2062 app.set_provider_identity(ApiProvider::Deepseek, "deepseek");
2063 app.set_model_selection("deepseek-v4-pro".to_string());
2064 let mut doc = build_document(&app, &config).expect("document");
2065 doc.settings.provider_models.clear();
2066 doc.runtime.model = "org/custom-router-id".to_string();
2067
2068 let error = apply_document(doc, &mut app, &mut config, false)
2069 .expect_err("official deepseek endpoint must reject non-DeepSeek ids");
2070
2071 assert!(
2072 error.to_string().contains("org/custom-router-id"),
2073 "error should name the rejected model, got: {error}"
2074 );
2075 assert_eq!(app.model, "deepseek-v4-pro");
2076 }
2077
2078 #[test]
2079 fn status_item_only_apply_does_not_require_engine_sync() {
2080 let _lock = lock_test_env();
2081 let dir = tempfile::tempdir().expect("isolated config dir");
2082 let mut app = app();
2083 app.config_path = Some(dir.path().join("config.toml"));
2084 let mut config = Config::default();
2085 let mut doc = build_document(&app, &config).expect("document");
2086 doc.config.status_items = vec![StatusItemValue::Cost, StatusItemValue::Model];
2087
2088 let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
2089
2090 assert!(outcome.changed);
2091 assert!(!outcome.requires_engine_sync);
2092 }
2093 }
2094
2094 lines RUST