返回 DeepSeek-TUI-2026
config_ui.rs
根目录 / crates / tui / src / config_ui.rs
1 #[cfg(feature = "web")]
2 use std::net::SocketAddr;
3 #[cfg(feature = "web")]
4 use std::process::Command;
5 #[cfg(feature = "web")]
6 use std::time::Duration;
7
8 use anyhow::{Context, Result, bail};
9 use schemars::{JsonSchema, schema_for};
10 use serde::{Deserialize, Serialize};
11 use serde_json::Value;
12
13 use crate::commands;
14 use crate::config::{Config, StatusItem, normalize_model_name};
15 use crate::localization::{normalize_configured_locale, resolve_locale};
16 use crate::settings::Settings;
17 use crate::tui::app::{
18 App, AppMode, ComposerDensity, ReasoningEffort, SidebarFocus, TranscriptSpacing,
19 };
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(title = "Current model")]
46 pub model: String,
47 pub approval_mode: ApprovalModeValue,
48 }
49
50 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
51 #[serde(rename_all = "snake_case")]
52 pub struct SettingsSection {
53 pub auto_compact: bool,
54 pub calm_mode: bool,
55 pub low_motion: bool,
56 pub fancy_animations: bool,
57 pub paste_burst_detection: bool,
58 pub show_thinking: bool,
59 pub show_tool_details: bool,
60 pub locale: UiLocale,
61 pub composer_density: ComposerDensityValue,
62 pub composer_border: bool,
63 pub transcript_spacing: TranscriptSpacingValue,
64 pub default_mode: DefaultModeValue,
65 #[schemars(range(min = 10, max = 50))]
66 pub sidebar_width: u16,
67 pub sidebar_focus: SidebarFocusValue,
68 #[schemars(range(min = 0))]
69 pub max_history: usize,
70 pub default_model: Option<String>,
71 }
72
73 #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
74 #[serde(rename_all = "snake_case")]
75 pub struct ConfigSection {
76 pub mcp_config_path: String,
77 pub reasoning_effort: ReasoningEffortValue,
78 #[schemars(title = "Status line items")]
79 pub status_items: Vec<StatusItemValue>,
80 }
81
82 #[derive(Debug, Clone)]
83 pub struct ConfigUiApplyOutcome {
84 pub changed: bool,
85 pub final_message: String,
86 pub requires_engine_sync: bool,
87 }
88
89 #[cfg(feature = "web")]
90 #[derive(Debug)]
91 pub struct WebConfigSession {
92 #[allow(dead_code)]
93 task: tokio::task::JoinHandle<()>,
94 pub receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
95 pub addr: SocketAddr,
96 }
97
98 #[cfg(not(feature = "web"))]
99 #[derive(Debug)]
100 pub struct WebConfigSession {
101 #[allow(dead_code)]
102 pub receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
103 }
104
105 #[cfg(test)]
106 impl WebConfigSession {
107 pub(crate) fn for_test(
108 receiver: tokio::sync::mpsc::UnboundedReceiver<WebConfigSessionEvent>,
109 ) -> Self {
110 #[cfg(feature = "web")]
111 {
112 Self {
113 task: tokio::spawn(async {}),
114 receiver,
115 addr: SocketAddr::from(([127, 0, 0, 1], 0)),
116 }
117 }
118 #[cfg(not(feature = "web"))]
119 {
120 Self { receiver }
121 }
122 }
123 }
124
125 #[cfg_attr(not(feature = "web"), allow(dead_code))]
126 #[derive(Debug, Clone)]
127 pub enum WebConfigSessionEvent {
128 Draft(ConfigUiDocument),
129 Committed(ConfigUiDocument),
130 Failed(String),
131 }
132
133 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
134 #[serde(rename_all = "snake_case")]
135 pub enum ApprovalModeValue {
136 Auto,
137 Suggest,
138 Never,
139 }
140
141 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
142 pub enum UiLocale {
143 #[serde(rename = "auto")]
144 #[schemars(rename = "auto")]
145 Auto,
146 #[serde(rename = "en")]
147 #[schemars(rename = "en")]
148 En,
149 #[serde(rename = "ja")]
150 #[schemars(rename = "ja")]
151 Ja,
152 #[serde(rename = "zh-Hans")]
153 #[schemars(rename = "zh-Hans")]
154 ZhHans,
155 #[serde(rename = "pt-BR")]
156 #[schemars(rename = "pt-BR")]
157 PtBr,
158 }
159
160 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
161 #[serde(rename_all = "snake_case")]
162 pub enum ComposerDensityValue {
163 Compact,
164 Comfortable,
165 Spacious,
166 }
167
168 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
169 #[serde(rename_all = "snake_case")]
170 pub enum TranscriptSpacingValue {
171 Compact,
172 Comfortable,
173 Spacious,
174 }
175
176 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
177 #[serde(rename_all = "snake_case")]
178 pub enum DefaultModeValue {
179 Agent,
180 Plan,
181 Yolo,
182 }
183
184 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
185 #[serde(rename_all = "snake_case")]
186 pub enum SidebarFocusValue {
187 Auto,
188 Plan,
189 Todos,
190 Tasks,
191 Agents,
192 Context,
193 }
194
195 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
196 #[serde(rename_all = "snake_case")]
197 pub enum ReasoningEffortValue {
198 Off,
199 Low,
200 Medium,
201 High,
202 Auto,
203 Max,
204 }
205
206 #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
207 #[serde(rename_all = "snake_case")]
208 pub enum StatusItemValue {
209 Mode,
210 Model,
211 Cost,
212 Status,
213 Coherence,
214 Agents,
215 ReasoningReplay,
216 Cache,
217 ContextPercent,
218 GitBranch,
219 LastToolElapsed,
220 RateLimit,
221 }
222
223 pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> {
224 let raw = arg.unwrap_or("").trim();
225 // Bare `/config` opens the legacy native modal — it matches the rest
226 // of the deepseek-tui navy chrome out of the box. Power users can
227 // opt into the schemaui-driven editor with `/config tui`, or the
228 // browser surface with `/config web` (web feature only).
229 if raw.is_empty() || raw.eq_ignore_ascii_case("native") {
230 return Ok(ConfigUiMode::Native);
231 }
232 if raw.eq_ignore_ascii_case("tui") {
233 return Ok(ConfigUiMode::Tui);
234 }
235 if raw.eq_ignore_ascii_case("web") {
236 return Ok(ConfigUiMode::Web);
237 }
238 Err("Usage: /config [native|tui|web]".to_string())
239 }
240
241 pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> {
242 let settings = Settings::load().unwrap_or_default();
243 let reasoning_effort = config
244 .reasoning_effort()
245 .map(ReasoningEffortValue::from_setting)
246 .unwrap_or_else(|| app.reasoning_effort.into());
247 let default_model = settings.default_model.clone();
248 let status_items = app.status_items.iter().copied().map(Into::into).collect();
249 Ok(ConfigUiDocument {
250 runtime: RuntimeSection {
251 model: app.model.clone(),
252 approval_mode: app.approval_mode.into(),
253 },
254 settings: SettingsSection {
255 auto_compact: settings.auto_compact,
256 calm_mode: settings.calm_mode,
257 low_motion: settings.low_motion,
258 fancy_animations: settings.fancy_animations,
259 paste_burst_detection: settings.paste_burst_detection,
260 show_thinking: settings.show_thinking,
261 show_tool_details: settings.show_tool_details,
262 locale: UiLocale::from_setting(&settings.locale)?,
263 composer_density: settings.composer_density.as_str().into(),
264 composer_border: settings.composer_border,
265 transcript_spacing: settings.transcript_spacing.as_str().into(),
266 default_mode: settings.default_mode.as_str().into(),
267 sidebar_width: settings.sidebar_width_percent,
268 sidebar_focus: settings.sidebar_focus.as_str().into(),
269 max_history: settings.max_input_history,
270 default_model,
271 },
272 config: ConfigSection {
273 mcp_config_path: app.mcp_config_path.display().to_string(),
274 reasoning_effort,
275 status_items,
276 },
277 })
278 }
279
280 pub fn build_schema() -> Value {
281 let mut schema = serde_json::to_value(schema_for!(ConfigUiDocument)).expect("config ui schema");
282 schema["title"] = Value::String("DeepSeek TUI Config".to_string());
283 schema["description"] =
284 Value::String("Edit runtime and persisted TUI configuration.".to_string());
285 schema
286 }
287
288 #[cfg(feature = "tui")]
289 pub fn run_tui_editor(app: &App, config: &Config) -> Result<ConfigUiDocument> {
290 let document = build_document(app, config)?;
291 let value = SchemaUI::new(serde_json::to_value(document.clone())?)
292 .with_schema(build_schema())
293 .with_title("DeepSeek TUI Config")
294 .with_description("Edit persisted settings and live runtime knobs.")
295 .run(FrontendOptions::Tui(
296 UiOptions::default()
297 .with_confirm_exit(true)
298 .with_bool_labels("On", "Off")
299 .with_integer_step(1)
300 .with_integer_fast_step(5)
301 .with_help(true),
302 ))?;
303 parse_document(value)
304 }
305
306 #[cfg(feature = "web")]
307 pub async fn start_web_editor(app: &App, config: &Config) -> Result<WebConfigSession> {
308 let initial = serde_json::to_value(build_document(app, config)?)?;
309 let session = WebSessionBuilder::new(build_schema())
310 .with_initial_data(initial)
311 .with_title("DeepSeek TUI Config")
312 .with_description("Save updates the browser draft. Exit commits changes back to the TUI.")
313 .build()?;
314 let bound = bind_session(session, ServeOptions::default()).await?;
315 let addr = bound.local_addr();
316 let url = format!("http://{addr}");
317 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
318 let app_snapshot = build_document(app, config)?;
319 let task = tokio::spawn(async move {
320 let poll_tx = tx.clone();
321 let poll_url = format!("{url}/api/session");
322 let poll_task = tokio::spawn(async move {
323 let client = reqwest::Client::new();
324 let mut last: Option<ConfigUiDocument> = Some(app_snapshot);
325 loop {
326 tokio::time::sleep(Duration::from_millis(750)).await;
327 let response = match client.get(&poll_url).send().await {
328 Ok(response) => response,
329 Err(err) => {
330 let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!(
331 "config web poll failed: {err}"
332 )));
333 break;
334 }
335 };
336 if !response.status().is_success() {
337 continue;
338 }
339 let body: Value = match response.json().await {
340 Ok(body) => body,
341 Err(err) => {
342 let _ = poll_tx.send(WebConfigSessionEvent::Failed(format!(
343 "config web decode failed: {err}"
344 )));
345 break;
346 }
347 };
348 let Some(data) = body.get("data") else {
349 continue;
350 };
351 let doc = match parse_document(data.clone()) {
352 Ok(doc) => doc,
353 Err(_) => continue,
354 };
355 if last.as_ref() == Some(&doc) {
356 continue;
357 }
358 let _ = poll_tx.send(WebConfigSessionEvent::Draft(doc.clone()));
359 last = Some(doc);
360 }
361 });
362
363 let result = bound.run().await;
364 poll_task.abort();
365 match result {
366 Ok(value) => match parse_document(value) {
367 Ok(doc) => {
368 let _ = tx.send(WebConfigSessionEvent::Committed(doc));
369 }
370 Err(err) => {
371 let _ = tx.send(WebConfigSessionEvent::Failed(format!(
372 "config web result decode failed: {err}"
373 )));
374 }
375 },
376 Err(err) => {
377 let _ = tx.send(WebConfigSessionEvent::Failed(format!(
378 "config web session failed: {err}"
379 )));
380 }
381 }
382 });
383 Ok(WebConfigSession {
384 task,
385 receiver: rx,
386 addr,
387 })
388 }
389
390 pub fn apply_document(
391 doc: ConfigUiDocument,
392 app: &mut App,
393 config: &mut Config,
394 persist: bool,
395 ) -> Result<ConfigUiApplyOutcome> {
396 validate_document(&doc)?;
397 let mut notes = Vec::new();
398 let previous_compaction = app.compaction_config();
399 let previous_reasoning_effort = app.reasoning_effort;
400
401 for (key, value) in [
402 ("model", doc.runtime.model.as_str()),
403 ("approval_mode", doc.runtime.approval_mode.as_setting()),
404 ("auto_compact", bool_str(doc.settings.auto_compact)),
405 ("calm_mode", bool_str(doc.settings.calm_mode)),
406 ("low_motion", bool_str(doc.settings.low_motion)),
407 ("fancy_animations", bool_str(doc.settings.fancy_animations)),
408 (
409 "paste_burst_detection",
410 bool_str(doc.settings.paste_burst_detection),
411 ),
412 ("show_thinking", bool_str(doc.settings.show_thinking)),
413 (
414 "show_tool_details",
415 bool_str(doc.settings.show_tool_details),
416 ),
417 ("locale", doc.settings.locale.as_setting()),
418 (
419 "composer_density",
420 doc.settings.composer_density.as_setting(),
421 ),
422 ("composer_border", bool_str(doc.settings.composer_border)),
423 (
424 "transcript_spacing",
425 doc.settings.transcript_spacing.as_setting(),
426 ),
427 ("default_mode", doc.settings.default_mode.as_setting()),
428 ("sidebar_width", &doc.settings.sidebar_width.to_string()),
429 ("sidebar_focus", doc.settings.sidebar_focus.as_setting()),
430 ("max_history", &doc.settings.max_history.to_string()),
431 ("mcp_config_path", doc.config.mcp_config_path.as_str()),
432 ] {
433 let result = commands::set_config_value(app, key, value, persist);
434 if result.is_error {
435 bail!(
436 "{}",
437 result
438 .message
439 .unwrap_or_else(|| "config update failed".to_string())
440 );
441 }
442 if let Some(message) = result.message {
443 notes.push(message);
444 }
445 }
446
447 // default_model is only applied when persisting (it controls the model
448 // for future sessions). Processing it in the main loop would overwrite
449 // the runtime model the user just chose when persist=false (#346-fix).
450 if persist {
451 let default_model_val = doc.settings.default_model.as_deref().unwrap_or("default");
452 let result = commands::set_config_value(app, "default_model", default_model_val, true);
453 if result.is_error {
454 bail!(
455 "{}",
456 result
457 .message
458 .unwrap_or_else(|| "default_model update failed".to_string())
459 );
460 }
461 if let Some(message) = result.message {
462 notes.push(message);
463 }
464 }
465
466 apply_reasoning_effort(app, config, doc.config.reasoning_effort, persist)?;
467 let requires_engine_sync = app.compaction_config() != previous_compaction
468 || app.reasoning_effort != previous_reasoning_effort;
469
470 let new_status_items = parse_status_items(&doc.config.status_items);
471 if app.status_items != new_status_items {
472 app.status_items = new_status_items.clone();
473 app.needs_redraw = true;
474 if persist {
475 let path = commands::persist_status_items(&new_status_items)?;
476 notes.push(format!("status_items saved to {}", path.display()));
477 } else {
478 notes.push("status_items updated for this session".to_string());
479 }
480 }
481
482 if persist {
483 reload_runtime_config(app, config)?;
484 notes.extend(config_reload_notes(app, config));
485 }
486 let changed = !notes.is_empty();
487 let final_message = if notes.is_empty() {
488 if persist {
489 "Config unchanged".to_string()
490 } else {
491 "Runtime config unchanged".to_string()
492 }
493 } else {
494 notes.last().cloned().unwrap_or_default()
495 };
496 Ok(ConfigUiApplyOutcome {
497 changed,
498 final_message,
499 requires_engine_sync,
500 })
501 }
502
503 pub fn parse_document(value: Value) -> Result<ConfigUiDocument> {
504 serde_json::from_value(value).context("failed to decode config ui document")
505 }
506
507 #[cfg(feature = "web")]
508 pub fn open_browser(url: &str) -> Result<()> {
509 #[cfg(target_os = "macos")]
510 let mut command = {
511 let mut command = Command::new("open");
512 command.arg(url);
513 command
514 };
515 #[cfg(target_os = "linux")]
516 let mut command = {
517 let mut command = Command::new("xdg-open");
518 command.arg(url);
519 command
520 };
521 #[cfg(target_os = "windows")]
522 let mut command = {
523 let mut command = Command::new("cmd");
524 command.args(["/C", "start", "", url]);
525 command
526 };
527 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
528 return Err(anyhow::anyhow!(
529 "browser opening is unsupported on this platform"
530 ));
531
532 let status = command
533 .status()
534 .context("failed to launch browser command")?;
535 if !status.success() {
536 bail!("browser command exited with status {status}");
537 }
538 Ok(())
539 }
540
541 fn validate_document(doc: &ConfigUiDocument) -> Result<()> {
542 if !doc.runtime.model.trim().eq_ignore_ascii_case("auto")
543 && normalize_model_name(&doc.runtime.model).is_none()
544 {
545 bail!("invalid model '{}'", doc.runtime.model);
546 }
547 if doc.config.mcp_config_path.trim().is_empty() {
548 bail!("mcp_config_path cannot be empty");
549 }
550 Ok(())
551 }
552
553 fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> {
554 let reloaded = Config::load(app.config_path.clone(), app.config_profile.as_deref())?;
555 *config = reloaded.clone();
556 app.api_provider = reloaded.api_provider();
557 app.reasoning_effort = ReasoningEffort::from_setting(
558 reloaded
559 .reasoning_effort()
560 .unwrap_or_else(|| app.reasoning_effort.as_setting()),
561 );
562 app.last_effective_reasoning_effort = None;
563 app.update_model_compaction_budget();
564 app.mcp_config_path = reloaded.mcp_config_path();
565 app.skills_dir = reloaded.skills_dir();
566 app.ui_locale = resolve_locale(&Settings::load().unwrap_or_default().locale);
567 Ok(())
568 }
569
570 fn config_reload_notes(app: &App, config: &Config) -> Vec<String> {
571 let mut notes = Vec::new();
572 notes.push("Config saved and reloaded".to_string());
573 if app.mcp_restart_required {
574 notes.push(format!(
575 "MCP tool pool still requires restart after {}",
576 config.mcp_config_path().display()
577 ));
578 }
579 notes
580 }
581
582 fn apply_reasoning_effort(
583 app: &mut App,
584 config: &mut Config,
585 value: ReasoningEffortValue,
586 persist: bool,
587 ) -> Result<()> {
588 let effort: ReasoningEffort = value.into();
589 app.reasoning_effort = effort;
590 app.last_effective_reasoning_effort = None;
591 app.update_model_compaction_budget();
592 if persist {
593 commands::persist_root_string_key("reasoning_effort", effort.as_setting())?;
594 }
595 config.reasoning_effort = Some(effort.as_setting().to_string());
596 Ok(())
597 }
598
599 fn parse_status_items(items: &[StatusItemValue]) -> Vec<StatusItem> {
600 items.iter().copied().map(Into::into).collect()
601 }
602
603 impl ApprovalModeValue {
604 fn as_setting(self) -> &'static str {
605 match self {
606 Self::Auto => "auto",
607 Self::Suggest => "suggest",
608 Self::Never => "never",
609 }
610 }
611 }
612
613 impl UiLocale {
614 fn as_setting(self) -> &'static str {
615 match self {
616 Self::Auto => "auto",
617 Self::En => "en",
618 Self::Ja => "ja",
619 Self::ZhHans => "zh-Hans",
620 Self::PtBr => "pt-BR",
621 }
622 }
623
624 fn from_setting(value: &str) -> Result<Self> {
625 match normalize_configured_locale(value) {
626 Some("auto") => Ok(Self::Auto),
627 Some("en") => Ok(Self::En),
628 Some("ja") => Ok(Self::Ja),
629 Some("zh-Hans") => Ok(Self::ZhHans),
630 Some("pt-BR") => Ok(Self::PtBr),
631 Some(other) => bail!("unsupported locale '{other}'"),
632 None => bail!("invalid locale '{value}'"),
633 }
634 }
635 }
636
637 impl ComposerDensityValue {
638 fn as_setting(self) -> &'static str {
639 match self {
640 Self::Compact => "compact",
641 Self::Comfortable => "comfortable",
642 Self::Spacious => "spacious",
643 }
644 }
645 }
646
647 impl TranscriptSpacingValue {
648 fn as_setting(self) -> &'static str {
649 match self {
650 Self::Compact => "compact",
651 Self::Comfortable => "comfortable",
652 Self::Spacious => "spacious",
653 }
654 }
655 }
656
657 impl DefaultModeValue {
658 fn as_setting(self) -> &'static str {
659 match self {
660 Self::Agent => "agent",
661 Self::Plan => "plan",
662 Self::Yolo => "yolo",
663 }
664 }
665 }
666
667 impl SidebarFocusValue {
668 fn as_setting(self) -> &'static str {
669 match self {
670 Self::Auto => "auto",
671 Self::Plan => "plan",
672 Self::Todos => "todos",
673 Self::Tasks => "tasks",
674 Self::Agents => "agents",
675 Self::Context => "context",
676 }
677 }
678 }
679
680 impl From<ApprovalMode> for ApprovalModeValue {
681 fn from(value: ApprovalMode) -> Self {
682 match value {
683 ApprovalMode::Auto => Self::Auto,
684 ApprovalMode::Suggest => Self::Suggest,
685 ApprovalMode::Never => Self::Never,
686 }
687 }
688 }
689
690 impl From<ReasoningEffort> for ReasoningEffortValue {
691 fn from(value: ReasoningEffort) -> Self {
692 match value {
693 ReasoningEffort::Off => Self::Off,
694 ReasoningEffort::Low => Self::Low,
695 ReasoningEffort::Medium => Self::Medium,
696 ReasoningEffort::High => Self::High,
697 ReasoningEffort::Auto => Self::Auto,
698 ReasoningEffort::Max => Self::Max,
699 }
700 }
701 }
702
703 impl ReasoningEffortValue {
704 fn from_setting(value: &str) -> Self {
705 match ReasoningEffort::from_setting(value) {
706 ReasoningEffort::Off => Self::Off,
707 ReasoningEffort::Low => Self::Low,
708 ReasoningEffort::Medium => Self::Medium,
709 ReasoningEffort::High => Self::High,
710 ReasoningEffort::Auto => Self::Auto,
711 ReasoningEffort::Max => Self::Max,
712 }
713 }
714 }
715
716 impl From<ReasoningEffortValue> for ReasoningEffort {
717 fn from(value: ReasoningEffortValue) -> Self {
718 match value {
719 ReasoningEffortValue::Off => Self::Off,
720 ReasoningEffortValue::Low => Self::Low,
721 ReasoningEffortValue::Medium => Self::Medium,
722 ReasoningEffortValue::High => Self::High,
723 ReasoningEffortValue::Auto => Self::Auto,
724 ReasoningEffortValue::Max => Self::Max,
725 }
726 }
727 }
728
729 impl From<&str> for ComposerDensityValue {
730 fn from(value: &str) -> Self {
731 match ComposerDensity::from_setting(value) {
732 ComposerDensity::Compact => Self::Compact,
733 ComposerDensity::Comfortable => Self::Comfortable,
734 ComposerDensity::Spacious => Self::Spacious,
735 }
736 }
737 }
738
739 impl From<&str> for TranscriptSpacingValue {
740 fn from(value: &str) -> Self {
741 match TranscriptSpacing::from_setting(value) {
742 TranscriptSpacing::Compact => Self::Compact,
743 TranscriptSpacing::Comfortable => Self::Comfortable,
744 TranscriptSpacing::Spacious => Self::Spacious,
745 }
746 }
747 }
748
749 impl From<&str> for DefaultModeValue {
750 fn from(value: &str) -> Self {
751 match AppMode::from_setting(value) {
752 AppMode::Agent => Self::Agent,
753 AppMode::Plan => Self::Plan,
754 AppMode::Yolo => Self::Yolo,
755 }
756 }
757 }
758
759 impl From<&str> for SidebarFocusValue {
760 fn from(value: &str) -> Self {
761 match SidebarFocus::from_setting(value) {
762 SidebarFocus::Auto => Self::Auto,
763 SidebarFocus::Plan => Self::Plan,
764 SidebarFocus::Todos => Self::Todos,
765 SidebarFocus::Tasks => Self::Tasks,
766 SidebarFocus::Agents => Self::Agents,
767 SidebarFocus::Context => Self::Context,
768 }
769 }
770 }
771
772 impl From<StatusItem> for StatusItemValue {
773 fn from(value: StatusItem) -> Self {
774 match value {
775 StatusItem::Mode => Self::Mode,
776 StatusItem::Model => Self::Model,
777 StatusItem::Cost => Self::Cost,
778 StatusItem::Status => Self::Status,
779 StatusItem::Coherence => Self::Coherence,
780 StatusItem::Agents => Self::Agents,
781 StatusItem::ReasoningReplay => Self::ReasoningReplay,
782 StatusItem::Cache => Self::Cache,
783 StatusItem::ContextPercent => Self::ContextPercent,
784 StatusItem::GitBranch => Self::GitBranch,
785 StatusItem::LastToolElapsed => Self::LastToolElapsed,
786 StatusItem::RateLimit => Self::RateLimit,
787 }
788 }
789 }
790
791 impl From<StatusItemValue> for StatusItem {
792 fn from(value: StatusItemValue) -> Self {
793 match value {
794 StatusItemValue::Mode => Self::Mode,
795 StatusItemValue::Model => Self::Model,
796 StatusItemValue::Cost => Self::Cost,
797 StatusItemValue::Status => Self::Status,
798 StatusItemValue::Coherence => Self::Coherence,
799 StatusItemValue::Agents => Self::Agents,
800 StatusItemValue::ReasoningReplay => Self::ReasoningReplay,
801 StatusItemValue::Cache => Self::Cache,
802 StatusItemValue::ContextPercent => Self::ContextPercent,
803 StatusItemValue::GitBranch => Self::GitBranch,
804 StatusItemValue::LastToolElapsed => Self::LastToolElapsed,
805 StatusItemValue::RateLimit => Self::RateLimit,
806 }
807 }
808 }
809
810 fn bool_str(value: bool) -> &'static str {
811 if value { "true" } else { "false" }
812 }
813
814 #[cfg(test)]
815 mod tests {
816 use super::*;
817 use crate::config::Config;
818 use crate::test_support::lock_test_env;
819 use crate::tui::app::{App, TuiOptions};
820 use std::fs;
821 use std::path::PathBuf;
822 use std::time::{SystemTime, UNIX_EPOCH};
823
824 fn app() -> App {
825 let options = TuiOptions {
826 model: "deepseek-v4-pro".to_string(),
827 workspace: PathBuf::from("."),
828 config_path: None,
829 config_profile: None,
830 allow_shell: false,
831 use_alt_screen: false,
832 use_mouse_capture: false,
833 use_bracketed_paste: true,
834 max_subagents: 1,
835 skills_dir: PathBuf::from("."),
836 memory_path: PathBuf::from("memory.md"),
837 notes_path: PathBuf::from("notes.txt"),
838 mcp_config_path: PathBuf::from("mcp.json"),
839 use_memory: false,
840 start_in_agent_mode: false,
841 skip_onboarding: true,
842 yolo: false,
843 resume_session_id: None,
844 initial_input: None,
845 };
846 App::new(options, &Config::default())
847 }
848
849 #[test]
850 fn build_document_reflects_app_state() {
851 let mut app = app();
852 app.auto_model = false;
853 app.model = "deepseek-v4-pro".to_string();
854 app.reasoning_effort = ReasoningEffort::Max;
855 let config = Config::default();
856 let doc = build_document(&app, &config).expect("document");
857 assert_eq!(doc.runtime.model, app.model);
858 assert_eq!(doc.runtime.approval_mode, ApprovalModeValue::Suggest);
859 assert_eq!(doc.config.reasoning_effort, ReasoningEffortValue::Max);
860 }
861
862 #[test]
863 fn schema_contains_typed_enums() {
864 let schema = build_schema();
865 let approval_mode = &schema["$defs"]["ApprovalModeValue"]["enum"];
866 assert_eq!(
867 approval_mode,
868 &serde_json::json!(["auto", "suggest", "never"])
869 );
870 let locale = &schema["$defs"]["UiLocale"]["enum"];
871 assert_eq!(
872 locale,
873 &serde_json::json!(["auto", "en", "ja", "zh-Hans", "pt-BR"])
874 );
875 }
876
877 #[test]
878 fn parse_document_roundtrip() {
879 let _lock = lock_test_env();
880 let app = app();
881 let config = Config::default();
882 let doc = build_document(&app, &config).expect("document");
883 let value = serde_json::to_value(doc.clone()).expect("json");
884 let parsed = parse_document(value).expect("parsed");
885 assert_eq!(parsed, doc);
886 }
887
888 #[test]
889 fn session_only_apply_keeps_runtime_overrides_and_skips_reload() {
890 let _lock = lock_test_env();
891 let nanos = SystemTime::now()
892 .duration_since(UNIX_EPOCH)
893 .expect("clock")
894 .as_nanos();
895 let temp_root = std::env::temp_dir().join(format!(
896 "deepseek-config-ui-session-only-{}-{}",
897 std::process::id(),
898 nanos
899 ));
900 fs::create_dir_all(temp_root.join(".deepseek")).expect("config dir");
901 let config_path = temp_root.join(".deepseek").join("config.toml");
902 fs::write(
903 &config_path,
904 r#"
905 model = "deepseek-v4-pro"
906 reasoning_effort = "max"
907 mcp_config_path = "disk-mcp.json"
908 "#,
909 )
910 .expect("seed config");
911
912 let mut app = app();
913 app.config_path = Some(config_path.clone());
914 app.model = "deepseek-v4-pro".to_string();
915 app.mcp_config_path = PathBuf::from("disk-mcp.json");
916 app.reasoning_effort = ReasoningEffort::Max;
917 let mut config = Config::load(Some(config_path), None).expect("load config");
918
919 let mut doc = build_document(&app, &config).expect("document");
920 doc.runtime.model = "deepseek-v4-flash".to_string();
921 doc.config.reasoning_effort = ReasoningEffortValue::Low;
922 doc.config.mcp_config_path = "session-mcp.json".to_string();
923
924 let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
925
926 assert!(outcome.changed);
927 assert!(outcome.requires_engine_sync);
928 assert_eq!(app.model, "deepseek-v4-flash");
929 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
930 assert_eq!(app.mcp_config_path, PathBuf::from("session-mcp.json"));
931 assert_eq!(
932 config.reasoning_effort.as_deref(),
933 Some(ReasoningEffort::Low.as_setting())
934 );
935 assert_eq!(
936 config.mcp_config_path.as_deref(),
937 Some("disk-mcp.json"),
938 "session-only apply must not reload persisted config back into runtime state"
939 );
940 }
941
942 #[test]
943 fn status_item_only_apply_does_not_require_engine_sync() {
944 let _lock = lock_test_env();
945 let mut app = app();
946 let mut config = Config::default();
947 let mut doc = build_document(&app, &config).expect("document");
948 doc.config.status_items = vec![StatusItemValue::Cost, StatusItemValue::Model];
949
950 let outcome = apply_document(doc, &mut app, &mut config, false).expect("apply");
951
952 assert!(outcome.changed);
953 assert!(!outcome.requires_engine_sync);
954 }
955 }
956
956 lines RUST