返回 DeepSeek-TUI-2026
config.rs
根目录 / crates / tui / src / commands / config.rs
1 //! Config commands: config, settings, mode switches, trust, logout
2
3 use std::path::{Path, PathBuf};
4 use std::time::Duration;
5
6 use super::CommandResult;
7 use crate::client::DeepSeekClient;
8 use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
9 use crate::config_ui::{ConfigUiMode, parse_mode};
10 use crate::llm_client::LlmClient;
11 use crate::localization::resolve_locale;
12 use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt};
13 use crate::settings::Settings;
14 use crate::tui::app::{App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus};
15 use crate::tui::approval::ApprovalMode;
16 use anyhow::Result;
17
18 /// Open the interactive config editor.
19 ///
20 /// Bare `/config` opens the legacy Native modal (the `OpenConfigView` action),
21 /// preserving the v0.8.4 behaviour. `/config tui` opens the new
22 /// schemaui-driven TUI editor; `/config web` launches the web editor (only
23 /// available in builds compiled with the `web` feature).
24 pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult {
25 let mode = match parse_mode(arg) {
26 Ok(mode) => mode,
27 Err(err) => return CommandResult::error(err),
28 };
29 if mode == ConfigUiMode::Web && !cfg!(feature = "web") {
30 return CommandResult::error(
31 "This build does not include the web config UI. Rebuild with the `web` feature.",
32 );
33 }
34 let action = match mode {
35 ConfigUiMode::Native => AppAction::OpenConfigView,
36 ConfigUiMode::Tui | ConfigUiMode::Web => AppAction::OpenConfigEditor(mode),
37 };
38 CommandResult::action(action)
39 }
40
41 /// Dispatch `/config` with optional args.
42 ///
43 /// - `/config` (no args) — opens the schemaui-driven TUI editor.
44 /// - `/config tui` / `/config web` / `/config native` — open a specific
45 /// editor mode (web requires the `web` build feature).
46 /// - `/config <key>` — shows the current value of a setting.
47 /// - `/config <key> <value>` — sets a runtime value (session only, no --save).
48 pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
49 let raw = arg.map(str::trim).unwrap_or("");
50 if raw.is_empty() {
51 return show_config(app, None);
52 }
53 let parts: Vec<&str> = raw.splitn(2, ' ').collect();
54 if parts.len() == 1 {
55 // Single arg: editor-mode shortcut OR show-value request.
56 let token = parts[0];
57 if matches!(
58 token.to_ascii_lowercase().as_str(),
59 "tui" | "web" | "native"
60 ) {
61 return show_config(app, Some(token));
62 }
63 // `/config <key>` — show current value
64 show_single_setting(app, token)
65 } else {
66 // `/config <key> <value>` — set value
67 set_config_value(app, parts[0], parts[1], false)
68 }
69 }
70
71 /// Show the current value of a single setting.
72 fn show_single_setting(app: &App, key: &str) -> CommandResult {
73 let key = key.to_lowercase();
74 fn locale_display(l: crate::localization::Locale) -> &'static str {
75 match l {
76 crate::localization::Locale::En => "en",
77 crate::localization::Locale::ZhHans => "zh-Hans",
78 crate::localization::Locale::Ja => "ja",
79 crate::localization::Locale::PtBr => "pt-BR",
80 }
81 }
82 fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str {
83 match d {
84 crate::tui::app::ComposerDensity::Compact => "compact",
85 crate::tui::app::ComposerDensity::Comfortable => "comfortable",
86 crate::tui::app::ComposerDensity::Spacious => "spacious",
87 }
88 }
89 fn spacing_display(s: crate::tui::app::TranscriptSpacing) -> &'static str {
90 match s {
91 crate::tui::app::TranscriptSpacing::Compact => "compact",
92 crate::tui::app::TranscriptSpacing::Comfortable => "comfortable",
93 crate::tui::app::TranscriptSpacing::Spacious => "spacious",
94 }
95 }
96 let value = match key.as_str() {
97 "model" => {
98 if app.auto_model {
99 let mut label = "auto (auto-select model per turn)".to_string();
100 if let Some(effective) = app.last_effective_model.as_deref()
101 && effective != "auto"
102 {
103 label.push_str(&format!("; last: {effective}"));
104 }
105 Some(label)
106 } else {
107 Some(app.model.clone())
108 }
109 }
110 "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()),
111 "locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
112 "auto_compact" | "compact" => {
113 Some(if app.auto_compact { "true" } else { "false" }.to_string())
114 }
115 "calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()),
116 "show_thinking" | "thinking" => {
117 Some(if app.show_thinking { "true" } else { "false" }.to_string())
118 }
119 "mode" | "default_mode" => Some(app.mode.as_setting().to_string()),
120 "max_history" | "history" => Some(app.max_input_history.to_string()),
121 "sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()),
122 "sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()),
123 "composer_density" | "composer" => Some(density_display(app.composer_density).to_string()),
124 "composer_border" | "border" => {
125 Some(if app.composer_border { "true" } else { "false" }.to_string())
126 }
127 "transcript_spacing" | "spacing" => {
128 Some(spacing_display(app.transcript_spacing).to_string())
129 }
130 _ => {
131 let known = Settings::available_settings()
132 .iter()
133 .any(|(k, _)| k == &key);
134 if known {
135 Some("(see /settings for current value)".to_string())
136 } else {
137 None
138 }
139 }
140 };
141 match value {
142 Some(v) => CommandResult::message(format!("{key} = {v}")),
143 None => CommandResult::error(format!(
144 "Unknown setting '{key}'. See `/help config` for available settings."
145 )),
146 }
147 }
148
149 /// Show persistent settings
150 pub fn show_settings(app: &mut App) -> CommandResult {
151 match Settings::load() {
152 Ok(settings) => CommandResult::message(settings.display(app.ui_locale)),
153 Err(e) => CommandResult::error(format!("Failed to load settings: {e}")),
154 }
155 }
156
157 /// Open the `/statusline` multi-select picker for configuring footer items.
158 pub fn status_line(_app: &mut App) -> CommandResult {
159 CommandResult::action(AppAction::OpenStatusPicker)
160 }
161
162 /// Persist `tui.status_items` to `~/.deepseek/config.toml` without disturbing
163 /// the rest of the file. We round-trip through `toml::Value` so any keys we
164 /// don't know about (provider blocks, MCP, etc.) survive the write
165 /// untouched.
166 ///
167 /// Returns the path written so the caller can surface it in a status toast.
168 pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result<PathBuf> {
169 use anyhow::Context;
170 use std::fs;
171
172 let path = config_toml_path()?;
173 if let Some(parent) = path.parent() {
174 fs::create_dir_all(parent)
175 .with_context(|| format!("failed to create config directory {}", parent.display()))?;
176 }
177
178 let mut doc: toml::Value = if path.exists() {
179 let raw = fs::read_to_string(&path)
180 .with_context(|| format!("failed to read config at {}", path.display()))?;
181 toml::from_str(&raw)
182 .with_context(|| format!("failed to parse config at {}", path.display()))?
183 } else {
184 toml::Value::Table(toml::value::Table::new())
185 };
186
187 let table = doc
188 .as_table_mut()
189 .context("config.toml root must be a table")?;
190 let tui_entry = table
191 .entry("tui".to_string())
192 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
193 let tui_table = tui_entry
194 .as_table_mut()
195 .context("`tui` section in config.toml must be a table")?;
196 let array = items
197 .iter()
198 .map(|item| toml::Value::String(item.key().to_string()))
199 .collect::<Vec<_>>();
200 tui_table.insert("status_items".to_string(), toml::Value::Array(array));
201
202 let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
203 fs::write(&path, body)
204 .with_context(|| format!("failed to write config at {}", path.display()))?;
205 Ok(path)
206 }
207
208 pub fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result<PathBuf> {
209 use anyhow::Context;
210 use std::fs;
211
212 let path = config_toml_path()?;
213 if let Some(parent) = path.parent() {
214 fs::create_dir_all(parent)
215 .with_context(|| format!("failed to create config directory {}", parent.display()))?;
216 }
217
218 let mut doc: toml::Value = if path.exists() {
219 let raw = fs::read_to_string(&path)
220 .with_context(|| format!("failed to read config at {}", path.display()))?;
221 toml::from_str(&raw)
222 .with_context(|| format!("failed to parse config at {}", path.display()))?
223 } else {
224 toml::Value::Table(toml::value::Table::new())
225 };
226 let table = doc
227 .as_table_mut()
228 .context("config.toml root must be a table")?;
229 table.insert(key.to_string(), toml::Value::String(value.to_string()));
230 let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
231 fs::write(&path, body)
232 .with_context(|| format!("failed to write config at {}", path.display()))?;
233 Ok(path)
234 }
235
236 /// Resolve the path to `~/.deepseek/config.toml` (or
237 /// `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we
238 /// never write to a different file than the one we read.
239 pub(super) fn config_toml_path() -> anyhow::Result<PathBuf> {
240 use anyhow::Context;
241 if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") {
242 let trimmed = env.trim();
243 if !trimmed.is_empty() {
244 return Ok(PathBuf::from(trimmed));
245 }
246 }
247 let home = dirs::home_dir().context("failed to resolve home directory for config.toml path")?;
248 Ok(home.join(".deepseek").join("config.toml"))
249 }
250
251 /// Modify a setting at runtime
252 pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
253 let key = key.to_lowercase();
254
255 match key.as_str() {
256 "model" => {
257 // Support "/model auto" — auto-select model based on request complexity
258 if value.trim().eq_ignore_ascii_case("auto") {
259 app.auto_model = true;
260 app.model = "auto".to_string();
261 app.last_effective_model = None;
262 app.reasoning_effort = ReasoningEffort::Auto;
263 app.last_effective_reasoning_effort = None;
264 app.update_model_compaction_budget();
265 app.session.last_prompt_tokens = None;
266 app.session.last_completion_tokens = None;
267 return CommandResult::with_message_and_action(
268 "model = auto (auto-select model and thinking per turn)".to_string(),
269 AppAction::UpdateCompaction(app.compaction_config()),
270 );
271 }
272 // Clear auto mode when a specific model is set
273 app.auto_model = false;
274 app.last_effective_model = None;
275 let Some(model) = normalize_model_name(value) else {
276 return CommandResult::error(format!(
277 "Invalid model '{value}'. Expected a DeepSeek model ID. Common models: {}",
278 COMMON_DEEPSEEK_MODELS.join(", ")
279 ));
280 };
281 app.model = model.clone();
282 app.update_model_compaction_budget();
283 app.session.last_prompt_tokens = None;
284 app.session.last_completion_tokens = None;
285 return CommandResult::with_message_and_action(
286 format!("model = {model}"),
287 AppAction::UpdateCompaction(app.compaction_config()),
288 );
289 }
290 "approval_mode" | "approval" => {
291 let mode = ApprovalMode::from_config_value(value);
292 return match mode {
293 Some(m) => {
294 app.approval_mode = m;
295 CommandResult::message(format!("approval_mode = {}", m.label()))
296 }
297 None => CommandResult::error(
298 "Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never/deny",
299 ),
300 };
301 }
302 "mcp_config_path" | "mcp" => {
303 if value.trim().is_empty() {
304 return CommandResult::error("mcp_config_path cannot be empty");
305 }
306 app.mcp_config_path = PathBuf::from(expand_tilde(value));
307 app.mcp_restart_required = true;
308 let message = if persist {
309 match persist_root_string_key("mcp_config_path", value) {
310 Ok(path) => format!(
311 "mcp_config_path = {} (saved to {}; restart required for MCP tool pool)",
312 app.mcp_config_path.display(),
313 path.display()
314 ),
315 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
316 }
317 } else {
318 format!(
319 "mcp_config_path = {} (session only; restart required for MCP tool pool)",
320 app.mcp_config_path.display()
321 )
322 };
323 return CommandResult::message(message);
324 }
325 _ => {}
326 }
327
328 let mut settings = match Settings::load() {
329 Ok(s) => s,
330 Err(e) if !persist => {
331 app.status_message = Some(format!(
332 "Settings unavailable; applying session-only override ({e})"
333 ));
334 Settings::default()
335 }
336 Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
337 };
338
339 if let Err(e) = settings.set(&key, value) {
340 return CommandResult::error(format!("{e}"));
341 }
342
343 let mut action = None;
344 match key.as_str() {
345 "auto_compact" | "compact" => {
346 app.auto_compact = settings.auto_compact;
347 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
348 }
349 "calm_mode" | "calm" => {
350 app.calm_mode = settings.calm_mode;
351 app.mark_history_updated();
352 }
353 "low_motion" | "motion" => {
354 app.low_motion = settings.low_motion;
355 app.needs_redraw = true;
356 }
357 "show_thinking" | "thinking" => {
358 app.show_thinking = settings.show_thinking;
359 app.mark_history_updated();
360 }
361 "show_tool_details" | "tool_details" => {
362 app.show_tool_details = settings.show_tool_details;
363 app.mark_history_updated();
364 }
365 "locale" | "language" => {
366 app.ui_locale = resolve_locale(&settings.locale);
367 app.needs_redraw = true;
368 }
369 "cost_currency" | "currency" => {
370 app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
371 .unwrap_or(crate::pricing::CostCurrency::Usd);
372 app.needs_redraw = true;
373 }
374 "composer_density" | "composer" => {
375 app.composer_density =
376 crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
377 app.needs_redraw = true;
378 }
379 "composer_border" | "border" => {
380 app.composer_border = settings.composer_border;
381 app.needs_redraw = true;
382 }
383 "paste_burst_detection" | "paste_burst" => {
384 app.use_paste_burst_detection = settings.paste_burst_detection;
385 if !app.use_paste_burst_detection {
386 app.paste_burst.clear_after_explicit_paste();
387 }
388 }
389 "transcript_spacing" | "spacing" => {
390 app.transcript_spacing =
391 crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
392 app.mark_history_updated();
393 }
394 "default_mode" | "mode" => {
395 let mode = AppMode::from_setting(&settings.default_mode);
396 app.set_mode(mode);
397 }
398 "max_history" | "history" => {
399 app.max_input_history = settings.max_input_history;
400 }
401 "default_model" => {
402 if let Some(ref model) = settings.default_model {
403 app.auto_model = model.trim().eq_ignore_ascii_case("auto");
404 app.model.clone_from(model);
405 app.last_effective_model = None;
406 if app.auto_model {
407 app.reasoning_effort = ReasoningEffort::Auto;
408 app.last_effective_reasoning_effort = None;
409 }
410 app.update_model_compaction_budget();
411 app.session.last_prompt_tokens = None;
412 app.session.last_completion_tokens = None;
413 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
414 }
415 }
416 "sidebar_width" | "sidebar" => {
417 app.sidebar_width_percent = settings.sidebar_width_percent;
418 app.mark_history_updated();
419 }
420 "sidebar_focus" | "focus" => {
421 app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus));
422 }
423 _ => {}
424 }
425
426 let display_value = match key.as_str() {
427 "default_mode" | "mode" => settings.default_mode.clone(),
428 "cost_currency" | "currency" => settings.cost_currency.clone(),
429 _ => value.to_string(),
430 };
431
432 let message = if persist {
433 if let Err(e) = settings.save() {
434 return CommandResult::error(format!("Failed to save: {e}"));
435 }
436 format!("{key} = {display_value} (saved)")
437 } else {
438 format!("{key} = {display_value} (session only, add --save to persist)")
439 };
440
441 CommandResult {
442 message: Some(message),
443 action,
444 is_error: false,
445 }
446 }
447
448 /// Modify a setting at runtime
449 #[allow(dead_code)]
450 pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
451 let Some(args) = args else {
452 let available = Settings::available_settings()
453 .iter()
454 .map(|(k, d)| format!(" {k}: {d}"))
455 .collect::<Vec<_>>()
456 .join("\n");
457 return CommandResult::message(format!(
458 "Usage: /set <key> <value>\n\n\
459 Available settings:\n{available}\n\n\
460 Session-only settings:\n \
461 model: Current model\n \
462 approval_mode: auto | suggest | never\n\n\
463 Add --save to persist to settings file."
464 ));
465 };
466
467 let parts: Vec<&str> = args.splitn(2, ' ').collect();
468 if parts.len() < 2 {
469 return CommandResult::error("Usage: /set <key> <value>");
470 }
471
472 let key = parts[0].to_lowercase();
473 let (value, should_save) = if parts[1].ends_with(" --save") {
474 (parts[1].trim_end_matches(" --save").trim(), true)
475 } else {
476 (parts[1].trim(), false)
477 };
478
479 set_config_value(app, &key, value, should_save)
480 }
481
482 /// Enable YOLO mode (shell + trust + auto-approve)
483 pub fn yolo(app: &mut App) -> CommandResult {
484 app.set_mode(AppMode::Yolo);
485 CommandResult::message("YOLO mode enabled - shell + trust + auto-approve!")
486 }
487
488 /// Legacy alias for the removed normal mode.
489 pub fn normal_mode(app: &mut App) -> CommandResult {
490 app.set_mode(AppMode::Agent);
491 CommandResult::message("Normal mode was removed. Switched to Agent mode.")
492 }
493
494 /// Enable agent mode (autonomous tool use with approvals)
495 pub fn agent_mode(app: &mut App) -> CommandResult {
496 app.set_mode(AppMode::Agent);
497 CommandResult::message("Agent mode enabled.")
498 }
499
500 /// Enable plan mode (tool planning, then choose execution route)
501 pub fn plan_mode(app: &mut App) -> CommandResult {
502 app.set_mode(AppMode::Plan);
503 CommandResult::message(
504 "Plan mode enabled. Describe your goal and I will create a plan before execution.",
505 )
506 }
507
508 /// Manage workspace-level trust and the per-path allowlist.
509 ///
510 /// Subcommands:
511 /// - `/trust` – show current state and trusted external paths
512 /// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
513 /// - `/trust off` – disable workspace-level trust mode
514 /// - `/trust add <path>` – add a directory to the allowlist (#29)
515 /// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
516 /// - `/trust list` – list trusted external paths for this workspace
517 pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
518 let raw = arg.map(str::trim).unwrap_or("");
519 let mut parts = raw.splitn(2, char::is_whitespace);
520 let sub = parts.next().unwrap_or("").to_lowercase();
521 let rest = parts.next().map(str::trim).unwrap_or("");
522 let workspace = app.workspace.clone();
523
524 match sub.as_str() {
525 "" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
526 "on" | "enable" | "yes" | "y" => {
527 app.trust_mode = true;
528 CommandResult::message(
529 "Workspace trust mode enabled — agent file tools can now read/write any path. \
530 Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
531 )
532 }
533 "off" | "disable" | "no" | "n" => {
534 app.trust_mode = false;
535 CommandResult::message("Workspace trust mode disabled.")
536 }
537 "add" => trust_add(&workspace, rest),
538 "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
539 other => CommandResult::error(format!(
540 "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
541 )),
542 }
543 }
544
545 fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
546 let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
547 let mut lines = Vec::new();
548 lines.push(format!(
549 "Workspace trust mode: {}",
550 if app.trust_mode {
551 "enabled"
552 } else {
553 "disabled"
554 }
555 ));
556 if trust.paths().is_empty() {
557 if force_paths {
558 lines.push("No external paths trusted from this workspace.".to_string());
559 } else {
560 lines.push(
561 "No external paths trusted yet. Use `/trust add <path>` to allow a directory."
562 .to_string(),
563 );
564 }
565 } else {
566 lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
567 for path in trust.paths() {
568 lines.push(format!(" • {}", path.display()));
569 }
570 }
571 CommandResult::message(lines.join("\n"))
572 }
573
574 fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
575 if raw.is_empty() {
576 return CommandResult::error(
577 "Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
578 );
579 }
580 let path = PathBuf::from(expand_tilde(raw));
581 if !path.exists() {
582 return CommandResult::error(format!(
583 "Path not found: {} — supply an existing directory or file.",
584 path.display()
585 ));
586 }
587 match crate::workspace_trust::add(workspace, &path) {
588 Ok(stored) => CommandResult::message(format!(
589 "Added to trust list for this workspace: {}",
590 stored.display()
591 )),
592 Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
593 }
594 }
595
596 fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
597 if raw.is_empty() {
598 return CommandResult::error("Usage: /trust remove <path>");
599 }
600 let path = PathBuf::from(expand_tilde(raw));
601 match crate::workspace_trust::remove(workspace, &path) {
602 Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
603 Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
604 Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
605 }
606 }
607
608 fn expand_tilde(raw: &str) -> String {
609 if let Some(rest) = raw.strip_prefix("~/")
610 && let Some(home) = dirs::home_dir()
611 {
612 return home.join(rest).to_string_lossy().into_owned();
613 } else if raw == "~"
614 && let Some(home) = dirs::home_dir()
615 {
616 return home.to_string_lossy().into_owned();
617 }
618 raw.to_string()
619 }
620
621 /// Auto-select a model based on request complexity.
622 ///
623 /// Short messages (<100 chars) → Flash (fast & cheap).
624 /// Long messages (>500 chars) → Pro (powerful reasoning).
625 /// Messages with complex keywords → Pro.
626 /// Default → Flash (cost savings).
627 pub fn auto_model_heuristic(input: &str, _current_model: &str) -> String {
628 let len = input.chars().count();
629 let lower = input.to_lowercase();
630 let complex_keywords = [
631 "refactor",
632 "architecture",
633 "design",
634 "debug",
635 "security",
636 "review",
637 "audit",
638 "migrate",
639 "optimize",
640 "rewrite",
641 "implement",
642 "analyze",
643 ];
644 if complex_keywords.iter().any(|kw| lower.contains(kw)) {
645 return "deepseek-v4-pro".to_string();
646 }
647 // Short messages → Flash
648 if len < 100 {
649 return "deepseek-v4-flash".to_string();
650 }
651 // Long complex requests → Pro
652 if len > 500 {
653 return "deepseek-v4-pro".to_string();
654 }
655 // Default to Flash for cost savings
656 "deepseek-v4-flash".to_string()
657 }
658
659 #[derive(Debug, Clone, PartialEq, Eq)]
660 pub struct AutoRouteRecommendation {
661 pub model: String,
662 pub reasoning_effort: Option<ReasoningEffort>,
663 }
664
665 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
666 pub enum AutoRouteSource {
667 FlashRouter,
668 Heuristic,
669 }
670
671 impl AutoRouteSource {
672 #[must_use]
673 pub fn label(self) -> &'static str {
674 match self {
675 AutoRouteSource::FlashRouter => "flash-router",
676 AutoRouteSource::Heuristic => "heuristic",
677 }
678 }
679 }
680
681 #[derive(Debug, Clone, PartialEq, Eq)]
682 pub struct AutoRouteSelection {
683 pub model: String,
684 pub reasoning_effort: Option<ReasoningEffort>,
685 pub source: AutoRouteSource,
686 }
687
688 pub const AUTO_MODEL_ROUTER_SYSTEM_PROMPT: &str = "\
689 You are the DeepSeek TUI auto-routing classifier. Return only compact JSON: \
690 {\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \
691 Use deepseek-v4-flash for trivial, conversational, status, or single-step work. \
692 Use deepseek-v4-pro for coding, debugging, release work, multi-step tasks, high-risk decisions, \
693 tool-heavy work, ambiguous requests, or anything that benefits from deeper reasoning. \
694 Use thinking off only for trivial no-tool answers, high for ordinary reasoning, and max for \
695 agentic, coding, multi-file, release, architecture, debugging, security, tool-heavy, or uncertain work.";
696
697 /// Parse the Flash router's JSON-only response.
698 ///
699 /// The runtime treats classifier output as untrusted: only known V4 model IDs
700 /// and supported reasoning tiers are accepted. Anything else falls back to the
701 /// deterministic heuristic.
702 pub fn parse_auto_route_recommendation(raw: &str) -> Option<AutoRouteRecommendation> {
703 let json = extract_first_json_object(raw)?;
704 let value: serde_json::Value = serde_json::from_str(json).ok()?;
705 let model = value.get("model").and_then(serde_json::Value::as_str)?;
706 let model = normalize_auto_route_model(model)?;
707 let reasoning_effort = value
708 .get("thinking")
709 .or_else(|| value.get("reasoning_effort"))
710 .or_else(|| value.get("effort"))
711 .and_then(serde_json::Value::as_str)
712 .and_then(parse_auto_route_reasoning_effort);
713
714 Some(AutoRouteRecommendation {
715 model: model.to_string(),
716 reasoning_effort,
717 })
718 }
719
720 fn extract_first_json_object(raw: &str) -> Option<&str> {
721 let start = raw.find('{')?;
722 let end = raw.rfind('}')?;
723 (end >= start).then_some(&raw[start..=end])
724 }
725
726 fn normalize_auto_route_model(model: &str) -> Option<&'static str> {
727 match model.trim().to_ascii_lowercase().as_str() {
728 "deepseek-v4-pro" | "v4-pro" | "pro" => Some("deepseek-v4-pro"),
729 "deepseek-v4-flash" | "v4-flash" | "flash" => Some("deepseek-v4-flash"),
730 _ => None,
731 }
732 }
733
734 fn parse_auto_route_reasoning_effort(effort: &str) -> Option<ReasoningEffort> {
735 match effort.trim().to_ascii_lowercase().as_str() {
736 "off" | "disabled" | "none" | "false" => Some(ReasoningEffort::Off),
737 "low" | "minimal" | "medium" | "mid" => Some(ReasoningEffort::High),
738 "high" => Some(ReasoningEffort::High),
739 "max" | "maximum" | "xhigh" => Some(ReasoningEffort::Max),
740 _ => None,
741 }
742 }
743
744 #[must_use]
745 pub fn normalize_auto_route_effort(effort: ReasoningEffort) -> ReasoningEffort {
746 match effort {
747 ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
748 other => other,
749 }
750 }
751
752 pub async fn resolve_auto_route_with_flash(
753 config: &crate::config::Config,
754 latest_request: &str,
755 recent_context: &str,
756 selected_model_mode: &str,
757 selected_thinking_mode: &str,
758 ) -> AutoRouteSelection {
759 match auto_route_flash_recommendation(
760 config,
761 latest_request,
762 recent_context,
763 selected_model_mode,
764 selected_thinking_mode,
765 )
766 .await
767 {
768 Ok(Some(recommendation)) => AutoRouteSelection {
769 model: recommendation.model,
770 reasoning_effort: recommendation.reasoning_effort,
771 source: AutoRouteSource::FlashRouter,
772 },
773 Ok(None) | Err(_) => fallback_auto_route(latest_request, selected_model_mode),
774 }
775 }
776
777 fn fallback_auto_route(latest_request: &str, selected_model_mode: &str) -> AutoRouteSelection {
778 AutoRouteSelection {
779 model: auto_model_heuristic(latest_request, selected_model_mode),
780 reasoning_effort: Some(normalize_auto_route_effort(crate::auto_reasoning::select(
781 false,
782 latest_request,
783 ))),
784 source: AutoRouteSource::Heuristic,
785 }
786 }
787
788 async fn auto_route_flash_recommendation(
789 config: &crate::config::Config,
790 latest_request: &str,
791 recent_context: &str,
792 selected_model_mode: &str,
793 selected_thinking_mode: &str,
794 ) -> Result<Option<AutoRouteRecommendation>> {
795 if cfg!(test) {
796 return Ok(None);
797 }
798
799 let client = DeepSeekClient::new(config)?;
800 let request = MessageRequest {
801 model: "deepseek-v4-flash".to_string(),
802 messages: vec![Message {
803 role: "user".to_string(),
804 content: vec![ContentBlock::Text {
805 text: auto_route_prompt(
806 latest_request,
807 recent_context,
808 selected_model_mode,
809 selected_thinking_mode,
810 ),
811 cache_control: None,
812 }],
813 }],
814 max_tokens: 96,
815 system: Some(SystemPrompt::Text(
816 AUTO_MODEL_ROUTER_SYSTEM_PROMPT.to_string(),
817 )),
818 tools: None,
819 tool_choice: None,
820 metadata: None,
821 thinking: None,
822 reasoning_effort: Some("off".to_string()),
823 stream: Some(false),
824 temperature: Some(0.0),
825 top_p: None,
826 };
827
828 let response =
829 tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??;
830 Ok(parse_auto_route_recommendation(&message_response_text(
831 &response,
832 )))
833 }
834
835 fn auto_route_prompt(
836 latest_request: &str,
837 recent_context: &str,
838 selected_model_mode: &str,
839 selected_thinking_mode: &str,
840 ) -> String {
841 format!(
842 "Session mode: agent\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.",
843 selected_model_mode,
844 selected_thinking_mode,
845 if recent_context.trim().is_empty() {
846 "No prior context."
847 } else {
848 recent_context
849 },
850 truncate_for_auto_router(latest_request, 4_000)
851 )
852 }
853
854 fn message_response_text(response: &MessageResponse) -> String {
855 let mut out = String::new();
856 for block in &response.content {
857 match block {
858 ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => {
859 append_router_text(&mut out, text);
860 }
861 ContentBlock::Thinking { thinking } => {
862 append_router_text(&mut out, thinking);
863 }
864 ContentBlock::ToolUse { name, .. } => {
865 append_router_text(&mut out, &format!("[tool call: {name}]"));
866 }
867 _ => {}
868 }
869 }
870 out
871 }
872
873 fn append_router_text(out: &mut String, text: &str) {
874 if !out.is_empty() {
875 out.push('\n');
876 }
877 out.push_str(text);
878 }
879
880 fn truncate_for_auto_router(text: &str, max_chars: usize) -> String {
881 let mut chars = text.chars();
882 let truncated: String = chars.by_ref().take(max_chars).collect();
883 if chars.next().is_some() {
884 format!("{truncated}...")
885 } else {
886 truncated
887 }
888 }
889
890 /// Toggle LSP diagnostics on/off or show status.
891 ///
892 /// - `/lsp on` — enable inline LSP diagnostics
893 /// - `/lsp off` — disable inline LSP diagnostics
894 /// - `/lsp status` — show whether diagnostics are currently enabled
895 pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
896 let raw = arg.map(str::trim).unwrap_or("");
897 // Access lsp_manager config through the App's engine handle
898 let current_enabled = app.lsp_enabled;
899
900 match raw {
901 "" | "status" => {
902 let status = if current_enabled { "on" } else { "off" };
903 CommandResult::message(format!(
904 "LSP diagnostics are currently **{status}**.\n\n\
905 Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits."
906 ))
907 }
908 "on" | "enable" | "1" | "true" => {
909 app.lsp_enabled = true;
910 CommandResult::message(
911 "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.",
912 )
913 }
914 "off" | "disable" | "0" | "false" => {
915 app.lsp_enabled = false;
916 CommandResult::message("LSP diagnostics disabled.")
917 }
918 other => CommandResult::error(format!(
919 "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`."
920 )),
921 }
922 }
923
924 /// Logout - clear API key and return to onboarding
925 pub fn logout(app: &mut App) -> CommandResult {
926 match clear_api_key() {
927 Ok(()) => {
928 app.onboarding = OnboardingState::ApiKey;
929 app.onboarding_needs_api_key = true;
930 app.api_key_input.clear();
931 app.api_key_cursor = 0;
932 CommandResult::message("Logged out. Enter a new API key to continue.")
933 }
934 Err(e) => CommandResult::error(format!("Failed to clear API key: {e}")),
935 }
936 }
937
938 #[cfg(test)]
939 mod tests {
940 use super::*;
941 use crate::config::Config;
942 use crate::test_support::lock_test_env;
943 use crate::tui::app::{App, TuiOptions};
944 use crate::tui::approval::ApprovalMode;
945 use std::env;
946 use std::ffi::OsString;
947 use std::fs;
948 use std::path::Path;
949 use std::path::PathBuf;
950 use std::time::{SystemTime, UNIX_EPOCH};
951
952 struct EnvGuard {
953 home: Option<OsString>,
954 userprofile: Option<OsString>,
955 deepseek_config_path: Option<OsString>,
956 }
957
958 impl EnvGuard {
959 fn new(home: &Path) -> Self {
960 let home_str = OsString::from(home.as_os_str());
961 let config_path = home.join(".deepseek").join("config.toml");
962 let config_str = OsString::from(config_path.as_os_str());
963 let home_prev = env::var_os("HOME");
964 let userprofile_prev = env::var_os("USERPROFILE");
965 let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
966
967 // Safety: test-only environment mutation guarded by a global mutex.
968 unsafe {
969 env::set_var("HOME", &home_str);
970 env::set_var("USERPROFILE", &home_str);
971 env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
972 }
973
974 Self {
975 home: home_prev,
976 userprofile: userprofile_prev,
977 deepseek_config_path: deepseek_config_prev,
978 }
979 }
980 }
981
982 impl Drop for EnvGuard {
983 fn drop(&mut self) {
984 if let Some(value) = self.home.take() {
985 // Safety: test-only environment mutation guarded by a global mutex.
986 unsafe {
987 env::set_var("HOME", value);
988 }
989 } else {
990 // Safety: test-only environment mutation guarded by a global mutex.
991 unsafe {
992 env::remove_var("HOME");
993 }
994 }
995
996 if let Some(value) = self.userprofile.take() {
997 // Safety: test-only environment mutation guarded by a global mutex.
998 unsafe {
999 env::set_var("USERPROFILE", value);
1000 }
1001 } else {
1002 // Safety: test-only environment mutation guarded by a global mutex.
1003 unsafe {
1004 env::remove_var("USERPROFILE");
1005 }
1006 }
1007
1008 if let Some(value) = self.deepseek_config_path.take() {
1009 // Safety: test-only environment mutation guarded by a global mutex.
1010 unsafe {
1011 env::set_var("DEEPSEEK_CONFIG_PATH", value);
1012 }
1013 } else {
1014 // Safety: test-only environment mutation guarded by a global mutex.
1015 unsafe {
1016 env::remove_var("DEEPSEEK_CONFIG_PATH");
1017 }
1018 }
1019 }
1020 }
1021
1022 fn create_test_app() -> App {
1023 let options = TuiOptions {
1024 model: "test-model".to_string(),
1025 workspace: PathBuf::from("."),
1026 config_path: None,
1027 config_profile: None,
1028 allow_shell: false,
1029 use_alt_screen: true,
1030 use_mouse_capture: false,
1031 use_bracketed_paste: true,
1032 max_subagents: 1,
1033 skills_dir: PathBuf::from("."),
1034 memory_path: PathBuf::from("memory.md"),
1035 notes_path: PathBuf::from("notes.txt"),
1036 mcp_config_path: PathBuf::from("mcp.json"),
1037 use_memory: false,
1038 start_in_agent_mode: false,
1039 skip_onboarding: false,
1040 yolo: false,
1041 resume_session_id: None,
1042 initial_input: None,
1043 };
1044 App::new(options, &Config::default())
1045 }
1046
1047 #[test]
1048 fn test_yolo_command_sets_all_flags() {
1049 let mut app = create_test_app();
1050 let _ = yolo(&mut app);
1051 assert!(app.allow_shell);
1052 assert!(app.trust_mode);
1053 assert!(app.yolo);
1054 assert_eq!(app.approval_mode, ApprovalMode::Auto);
1055 assert_eq!(app.mode, AppMode::Yolo);
1056 }
1057
1058 #[test]
1059 fn test_mode_switch_commands() {
1060 let mut app = create_test_app();
1061 let _ = normal_mode(&mut app);
1062 assert_eq!(app.mode, AppMode::Agent);
1063 let _ = agent_mode(&mut app);
1064 assert_eq!(app.mode, AppMode::Agent);
1065 let _ = plan_mode(&mut app);
1066 assert_eq!(app.mode, AppMode::Plan);
1067 }
1068
1069 #[test]
1070 fn test_show_config_defaults_to_native() {
1071 let mut app = create_test_app();
1072 app.session.total_tokens = 1234;
1073 let result = show_config(&mut app, None);
1074 assert!(result.message.is_none());
1075 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
1076 }
1077
1078 #[test]
1079 fn test_show_config_native_opens_legacy_editor() {
1080 let mut app = create_test_app();
1081 let result = show_config(&mut app, Some("native"));
1082 assert!(result.message.is_none());
1083 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
1084 }
1085
1086 #[test]
1087 fn test_show_settings_loads_from_file() {
1088 let _lock = lock_test_env();
1089 let mut app = create_test_app();
1090 let result = show_settings(&mut app);
1091 // Settings should load (may use defaults if file doesn't exist)
1092 assert!(result.message.is_some());
1093 }
1094
1095 #[test]
1096 fn test_set_without_args_shows_usage() {
1097 let mut app = create_test_app();
1098 let result = set_config(&mut app, None);
1099 assert!(result.message.is_some());
1100 let msg = result.message.unwrap();
1101 assert!(msg.contains("Usage: /set"));
1102 assert!(msg.contains("Available settings:"));
1103 }
1104
1105 #[test]
1106 fn test_set_model_updates_app_state() {
1107 let mut app = create_test_app();
1108 let _old_model = app.model.clone();
1109 let result = set_config(&mut app, Some("model deepseek-v4-flash"));
1110 assert!(result.message.is_some());
1111 let msg = result.message.unwrap();
1112 assert!(msg.contains("model = deepseek-v4-flash"));
1113 assert_eq!(app.model, "deepseek-v4-flash");
1114 assert!(matches!(
1115 result.action,
1116 Some(AppAction::UpdateCompaction(_))
1117 ));
1118 }
1119
1120 #[test]
1121 fn test_set_model_auto_enables_auto_thinking() {
1122 let mut app = create_test_app();
1123 app.reasoning_effort = ReasoningEffort::Off;
1124
1125 let result = set_config(&mut app, Some("model auto"));
1126
1127 assert!(result.message.is_some());
1128 assert!(app.auto_model);
1129 assert_eq!(app.model, "auto");
1130 assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
1131 assert!(app.last_effective_model.is_none());
1132 assert!(app.last_effective_reasoning_effort.is_none());
1133 }
1134
1135 #[test]
1136 fn test_set_model_accepts_future_deepseek_model_id() {
1137 let mut app = create_test_app();
1138 let result = set_config(&mut app, Some("model deepseek-v4"));
1139 assert!(result.message.is_some());
1140 let msg = result.message.unwrap();
1141 assert!(msg.contains("model = deepseek-v4"));
1142 assert_eq!(app.model, "deepseek-v4");
1143 }
1144
1145 #[test]
1146 fn test_set_model_with_save_flag() {
1147 let mut app = create_test_app();
1148 let _result = set_config(&mut app, Some("model deepseek-v4-flash --save"));
1149 // Note: This test may fail in environments where settings can't be saved
1150 // The important thing is that the model is updated
1151 assert_eq!(app.model, "deepseek-v4-flash");
1152 }
1153
1154 #[test]
1155 fn auto_route_recommendation_parses_strict_json() {
1156 let rec =
1157 parse_auto_route_recommendation(r#"{"model":"deepseek-v4-pro","thinking":"max"}"#)
1158 .expect("valid router response should parse");
1159
1160 assert_eq!(rec.model, "deepseek-v4-pro");
1161 assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Max));
1162 }
1163
1164 #[test]
1165 fn auto_route_recommendation_accepts_wrapped_json_aliases() {
1166 let rec =
1167 parse_auto_route_recommendation(r#"route: {"model":"flash","reasoning_effort":"off"}"#)
1168 .expect("wrapped router response should parse");
1169
1170 assert_eq!(rec.model, "deepseek-v4-flash");
1171 assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Off));
1172 }
1173
1174 #[test]
1175 fn auto_route_recommendation_normalizes_legacy_low_medium_to_high() {
1176 let rec = parse_auto_route_recommendation(
1177 r#"{"model":"deepseek-v4-pro","reasoning_effort":"medium"}"#,
1178 )
1179 .expect("medium should parse for back-compat");
1180
1181 assert_eq!(rec.model, "deepseek-v4-pro");
1182 assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::High));
1183 }
1184
1185 #[test]
1186 fn auto_route_recommendation_rejects_unknown_model() {
1187 assert!(
1188 parse_auto_route_recommendation(r#"{"model":"some-other-model","thinking":"max"}"#,)
1189 .is_none()
1190 );
1191 }
1192
1193 #[test]
1194 fn test_set_default_mode_normal_save_reports_normalized_value() {
1195 let _lock = lock_test_env();
1196 let nanos = SystemTime::now()
1197 .duration_since(UNIX_EPOCH)
1198 .unwrap()
1199 .as_nanos();
1200 let temp_root = env::temp_dir().join(format!(
1201 "deepseek-tui-default-mode-test-{}-{}",
1202 std::process::id(),
1203 nanos
1204 ));
1205 fs::create_dir_all(&temp_root).unwrap();
1206 let _guard = EnvGuard::new(&temp_root);
1207
1208 let mut app = create_test_app();
1209 let result = set_config(&mut app, Some("default_mode normal --save"));
1210 let msg = result.message.unwrap();
1211 assert_eq!(msg, "default_mode = agent (saved)");
1212 assert_eq!(app.mode, AppMode::Agent);
1213
1214 let settings_path = Settings::path().unwrap();
1215 let saved = fs::read_to_string(settings_path).unwrap();
1216 assert!(saved.contains("default_mode = \"agent\""));
1217 }
1218
1219 #[test]
1220 fn test_set_approval_mode_valid_values() {
1221 let mut app = create_test_app();
1222 // Test auto
1223 let result = set_config(&mut app, Some("approval_mode auto"));
1224 assert!(result.message.is_some());
1225 assert_eq!(app.approval_mode, ApprovalMode::Auto);
1226
1227 // Test suggest
1228 let result = set_config(&mut app, Some("approval_mode suggest"));
1229 assert!(result.message.is_some());
1230 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
1231
1232 // Test never
1233 let result = set_config(&mut app, Some("approval_mode never"));
1234 assert!(result.message.is_some());
1235 assert_eq!(app.approval_mode, ApprovalMode::Never);
1236 }
1237
1238 #[test]
1239 fn test_set_approval_mode_invalid_value() {
1240 let mut app = create_test_app();
1241 let result = set_config(&mut app, Some("approval_mode invalid"));
1242 assert!(result.message.is_some());
1243 let msg = result.message.unwrap();
1244 assert!(msg.contains("Invalid approval_mode"));
1245 }
1246
1247 #[test]
1248 fn test_set_without_save_flag() {
1249 let _lock = lock_test_env();
1250 let mut app = create_test_app();
1251 let result = set_config(&mut app, Some("auto_compact true"));
1252 assert!(result.message.is_some());
1253 let msg = result.message.unwrap();
1254 assert!(msg.contains("(session only"));
1255 }
1256
1257 #[test]
1258 fn test_set_composer_border_updates_live_app() {
1259 let _lock = lock_test_env();
1260 let mut app = create_test_app();
1261 app.composer_border = true;
1262
1263 let result = set_config(&mut app, Some("composer_border false"));
1264
1265 assert!(result.message.is_some());
1266 assert!(!app.composer_border);
1267 assert!(app.needs_redraw);
1268 }
1269
1270 #[test]
1271 fn test_trust_on_enables_flag() {
1272 let mut app = create_test_app();
1273 assert!(!app.trust_mode);
1274 let result = trust(&mut app, Some("on"));
1275 let msg = result.message.expect("message");
1276 assert!(msg.contains("Workspace trust mode enabled"));
1277 assert!(app.trust_mode);
1278 }
1279
1280 #[test]
1281 fn test_trust_status_default_lists_state() {
1282 let mut app = create_test_app();
1283 let result = trust(&mut app, None);
1284 let msg = result.message.expect("status message");
1285 assert!(msg.contains("Workspace trust mode"));
1286 }
1287
1288 #[test]
1289 fn test_trust_add_requires_path() {
1290 let mut app = create_test_app();
1291 let result = trust(&mut app, Some("add"));
1292 let msg = result.message.expect("error message");
1293 assert!(msg.starts_with("Error:"), "got {msg:?}");
1294 }
1295
1296 #[test]
1297 fn test_logout_clears_api_key_state() {
1298 let _lock = lock_test_env();
1299 let nanos = SystemTime::now()
1300 .duration_since(UNIX_EPOCH)
1301 .unwrap()
1302 .as_nanos();
1303 let temp_root = env::temp_dir().join(format!(
1304 "deepseek-tui-logout-test-{}-{}",
1305 std::process::id(),
1306 nanos
1307 ));
1308 fs::create_dir_all(&temp_root).unwrap();
1309 let _guard = EnvGuard::new(&temp_root);
1310
1311 let config_path = temp_root.join(".deepseek").join("config.toml");
1312 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1313 fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
1314
1315 let mut app = create_test_app();
1316 let result = logout(&mut app);
1317 assert!(result.message.is_some());
1318 assert_eq!(app.onboarding, OnboardingState::ApiKey);
1319 assert!(app.onboarding_needs_api_key);
1320 assert!(app.api_key_input.is_empty());
1321 assert_eq!(app.api_key_cursor, 0);
1322
1323 let updated = fs::read_to_string(config_path).unwrap();
1324 assert!(!updated.contains("api_key"));
1325 }
1326
1327 #[test]
1328 fn test_set_invalid_setting() {
1329 let _lock = lock_test_env();
1330 let mut app = create_test_app();
1331 let _result = set_config(&mut app, Some("nonexistent value"));
1332 // Should either error or handle as session setting
1333 // The current implementation tries to set it in Settings
1334 // which may succeed or fail depending on Settings implementation
1335 }
1336
1337 #[test]
1338 fn test_set_key_without_value() {
1339 let mut app = create_test_app();
1340 let result = set_config(&mut app, Some("model"));
1341 assert!(result.message.is_some());
1342 let msg = result.message.unwrap();
1343 assert!(msg.contains("Usage: /set"));
1344 }
1345
1346 #[test]
1347 fn persist_status_items_writes_tui_section_to_config_toml() {
1348 let _lock = lock_test_env();
1349 let nanos = SystemTime::now()
1350 .duration_since(UNIX_EPOCH)
1351 .unwrap()
1352 .as_nanos();
1353 let temp_root = env::temp_dir().join(format!(
1354 "deepseek-statusline-persist-{}-{}",
1355 std::process::id(),
1356 nanos
1357 ));
1358 fs::create_dir_all(&temp_root).unwrap();
1359 let _guard = EnvGuard::new(&temp_root);
1360
1361 let items = vec![
1362 crate::config::StatusItem::Mode,
1363 crate::config::StatusItem::Model,
1364 crate::config::StatusItem::Cost,
1365 ];
1366
1367 let path = persist_status_items(&items).expect("persist should succeed");
1368 let body = fs::read_to_string(&path).expect("written file should be readable");
1369 assert!(body.contains("[tui]"), "expected [tui] section in {body}");
1370 assert!(
1371 body.contains("status_items"),
1372 "expected status_items key in {body}"
1373 );
1374 assert!(body.contains("\"mode\""), "expected mode key in {body}");
1375 assert!(body.contains("\"cost\""), "expected cost key in {body}");
1376 }
1377
1378 #[test]
1379 fn persist_status_items_preserves_existing_unrelated_keys() {
1380 let _lock = lock_test_env();
1381 let nanos = SystemTime::now()
1382 .duration_since(UNIX_EPOCH)
1383 .unwrap()
1384 .as_nanos();
1385 let temp_root = env::temp_dir().join(format!(
1386 "deepseek-statusline-preserve-{}-{}",
1387 std::process::id(),
1388 nanos
1389 ));
1390 fs::create_dir_all(&temp_root).unwrap();
1391 let _guard = EnvGuard::new(&temp_root);
1392
1393 let path = temp_root.join(".deepseek").join("config.toml");
1394 fs::create_dir_all(path.parent().unwrap()).unwrap();
1395 // Seed the config with a sentinel key the picker MUST NOT clobber.
1396 fs::write(
1397 &path,
1398 "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n",
1399 )
1400 .unwrap();
1401
1402 let written = persist_status_items(&[crate::config::StatusItem::Mode])
1403 .expect("persist should succeed");
1404 let body = fs::read_to_string(&written).expect("written file should be readable");
1405 assert!(
1406 body.contains("api_key = \"sentinel-key\""),
1407 "round-trip lost api_key: {body}"
1408 );
1409 assert!(
1410 body.contains("model = \"deepseek-v4-pro\""),
1411 "round-trip lost model: {body}"
1412 );
1413 assert!(
1414 body.contains("status_items"),
1415 "expected status_items in {body}"
1416 );
1417 }
1418 }
1419
1419 lines RUST