返回 DeepSeek-TUI-2026
core.rs
根目录 / crates / tui / src / commands / core.rs
1 //! Core commands: help, clear, exit, model
2
3 use std::fmt::Write;
4
5 use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name};
6 use crate::localization::{MessageId, tr};
7 use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort};
8 use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
9
10 use super::CommandResult;
11
12 /// Show help information
13 pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult {
14 if let Some(topic) = topic {
15 // Show help for specific command
16 if let Some(cmd) = super::get_command_info(topic) {
17 let mut help = format!(
18 "{}\n\n {}\n\n {} {}",
19 cmd.name,
20 cmd.description_for(app.ui_locale),
21 tr(app.ui_locale, MessageId::HelpUsageLabel),
22 cmd.usage
23 );
24 if !cmd.aliases.is_empty() {
25 let _ = write!(
26 help,
27 "\n {} {}",
28 tr(app.ui_locale, MessageId::HelpAliasesLabel),
29 cmd.aliases.join(", ")
30 );
31 }
32 return CommandResult::message(help);
33 }
34 return CommandResult::error(
35 tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic),
36 );
37 }
38
39 // Show help overlay
40 if app.view_stack.top_kind() != Some(ModalKind::Help) {
41 app.view_stack.push(HelpView::new_for_locale(app.ui_locale));
42 }
43 CommandResult::ok()
44 }
45
46 /// Clear conversation history
47 pub fn clear(app: &mut App) -> CommandResult {
48 app.clear_history();
49 app.mark_history_updated();
50 app.api_messages.clear();
51 app.system_prompt = None;
52 app.viewport.transcript_selection.clear();
53 app.queued_messages.clear();
54 app.queued_draft = None;
55 app.session.total_conversation_tokens = 0;
56 let todos_cleared = app.clear_todos();
57 app.tool_log.clear();
58 app.tool_cells.clear();
59 app.tool_details_by_cell.clear();
60 app.exploring_entries.clear();
61 app.ignored_tool_calls.clear();
62 app.pending_tool_uses.clear();
63 app.last_exec_wait_command = None;
64 app.session.last_prompt_tokens = None;
65 app.session.last_completion_tokens = None;
66 app.current_session_id = None;
67 let locale = app.ui_locale;
68 let message = if todos_cleared {
69 tr(locale, MessageId::ClearConversation).to_string()
70 } else {
71 tr(locale, MessageId::ClearConversationBusy).to_string()
72 };
73 CommandResult::with_message_and_action(
74 message,
75 AppAction::SyncSession {
76 messages: Vec::new(),
77 system_prompt: None,
78 model: app.model.clone(),
79 workspace: app.workspace.clone(),
80 },
81 )
82 }
83
84 /// Exit the application
85 pub fn exit() -> CommandResult {
86 CommandResult::action(AppAction::Quit)
87 }
88
89 /// Switch or view current model. With no argument, open the two-pane
90 /// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
91 /// way to flip both knobs without memorising the docs.
92 pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
93 if let Some(name) = model_name {
94 if name.trim().eq_ignore_ascii_case("auto") {
95 let old_model = app.model_display_label();
96 app.auto_model = true;
97 app.model = "auto".to_string();
98 app.last_effective_model = None;
99 app.reasoning_effort = ReasoningEffort::Auto;
100 app.last_effective_reasoning_effort = None;
101 app.update_model_compaction_budget();
102 app.session.last_prompt_tokens = None;
103 app.session.last_completion_tokens = None;
104 return CommandResult::with_message_and_action(
105 tr(app.ui_locale, MessageId::ModelChanged)
106 .replace("{old}", &old_model)
107 .replace("{new}", "auto"),
108 AppAction::UpdateCompaction(app.compaction_config()),
109 );
110 }
111 let Some(model_id) = normalize_model_name(name) else {
112 return CommandResult::error(format!(
113 "Invalid model '{name}'. Expected auto or a DeepSeek model ID. Common models: {}",
114 COMMON_DEEPSEEK_MODELS.join(", ")
115 ));
116 };
117 let old_model = app.model_display_label();
118 app.auto_model = false;
119 app.model = model_id.clone();
120 app.last_effective_model = None;
121 app.update_model_compaction_budget();
122 app.session.last_prompt_tokens = None;
123 app.session.last_completion_tokens = None;
124 CommandResult::with_message_and_action(
125 tr(app.ui_locale, MessageId::ModelChanged)
126 .replace("{old}", &old_model)
127 .replace("{new}", &model_id),
128 AppAction::UpdateCompaction(app.compaction_config()),
129 )
130 } else {
131 CommandResult::action(AppAction::OpenModelPicker)
132 }
133 }
134
135 /// Fetch and list available models from the configured API endpoint.
136 pub fn models(_app: &mut App) -> CommandResult {
137 CommandResult::action(AppAction::FetchModels)
138 }
139
140 /// List sub-agent status from the engine
141 pub fn subagents(app: &mut App) -> CommandResult {
142 if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
143 app.view_stack
144 .push(SubAgentsView::new(app.subagent_cache.clone()));
145 }
146 app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string());
147 CommandResult::action(AppAction::ListSubAgents)
148 }
149
150 /// Switch to a configured profile.
151 pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult {
152 let profile_name = match arg {
153 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
154 _ => {
155 return CommandResult::error(
156 "Usage: /profile <name>\n\nSwitch to a named config profile. Profiles are defined in ~/.deepseek/config.toml under [profiles] sections.",
157 );
158 }
159 };
160 CommandResult::with_message_and_action(
161 format!("Switching to profile '{profile_name}'..."),
162 AppAction::SwitchProfile {
163 profile: profile_name,
164 },
165 )
166 }
167
168 /// Show `DeepSeek` dashboard and docs links
169 pub fn deepseek_links(app: &mut App) -> CommandResult {
170 let locale = app.ui_locale;
171 CommandResult::message(format!(
172 "{}\n\
173 ─────────────────────────────\n\
174 {} https://platform.deepseek.com\n\
175 {} https://platform.deepseek.com/docs\n\n\
176 {}",
177 tr(locale, MessageId::LinksTitle),
178 tr(locale, MessageId::LinksDashboard),
179 tr(locale, MessageId::LinksDocs),
180 tr(locale, MessageId::LinksTip),
181 ))
182 }
183
184 /// Show home dashboard with stats and quick actions
185 pub fn home_dashboard(app: &mut App) -> CommandResult {
186 let locale = app.ui_locale;
187 let mut stats = String::new();
188
189 // Basic info
190 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeDashboardTitle));
191 let _ = writeln!(stats, "============================================");
192
193 // Model & mode
194 let _ = writeln!(
195 stats,
196 "{} {}",
197 tr(locale, MessageId::HomeModel),
198 app.model
199 );
200 let _ = writeln!(
201 stats,
202 "{} {}",
203 tr(locale, MessageId::HomeMode),
204 app.mode.label()
205 );
206 let _ = writeln!(
207 stats,
208 "{} {}",
209 tr(locale, MessageId::HomeWorkspace),
210 app.workspace.display()
211 );
212
213 // Session stats
214 let history_count = app.history.len();
215 let total_tokens = app.session.total_conversation_tokens;
216 let queued_messages = app.queued_messages.len();
217 let _ = writeln!(
218 stats,
219 "{} {} messages",
220 tr(locale, MessageId::HomeHistory),
221 history_count
222 );
223 let _ = writeln!(
224 stats,
225 "{} {} (session)",
226 tr(locale, MessageId::HomeTokens),
227 total_tokens
228 );
229 if queued_messages > 0 {
230 let _ = writeln!(
231 stats,
232 "{} {} messages",
233 tr(locale, MessageId::HomeQueued),
234 queued_messages
235 );
236 }
237
238 // Sub-agents
239 let subagent_count = app.subagent_cache.len();
240 if subagent_count > 0 {
241 let _ = writeln!(
242 stats,
243 "{} {} active",
244 tr(locale, MessageId::HomeSubagents),
245 subagent_count
246 );
247 }
248
249 // Active skill
250 if let Some(skill) = &app.active_skill {
251 let _ = writeln!(
252 stats,
253 "{} {} (active)",
254 tr(locale, MessageId::HomeSkill),
255 skill
256 );
257 }
258
259 // Quick actions section
260 let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeQuickActions));
261 let _ = writeln!(stats, "--------------------------------------------");
262 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickLinks));
263 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSkills));
264 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickConfig));
265 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSettings));
266 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickModel));
267 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSubagents));
268 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTaskList));
269 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickHelp));
270
271 // Mode-specific tips
272 let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeModeTips));
273 let _ = writeln!(stats, "--------------------------------------------");
274 match app.mode {
275 AppMode::Agent => {
276 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeTip));
277 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeReviewTip));
278 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeYoloTip));
279 }
280 AppMode::Yolo => {
281 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeTip));
282 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeCaution));
283 }
284 AppMode::Plan => {
285 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip));
286 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip));
287 }
288 }
289
290 CommandResult::message(stats)
291 }
292
293 #[cfg(test)]
294 mod tests {
295 use super::*;
296 use crate::config::Config;
297 use crate::models::Message;
298 use crate::tui::app::{App, AppMode, TuiOptions};
299 use crate::tui::history::HistoryCell;
300 use std::path::PathBuf;
301
302 fn create_test_app() -> App {
303 let options = TuiOptions {
304 model: "deepseek-v4-pro".to_string(),
305 workspace: PathBuf::from("/tmp/test-workspace"),
306 config_path: None,
307 config_profile: None,
308 allow_shell: false,
309 use_alt_screen: true,
310 use_mouse_capture: false,
311 use_bracketed_paste: true,
312 max_subagents: 1,
313 skills_dir: PathBuf::from("/tmp/test-skills"),
314 memory_path: PathBuf::from("memory.md"),
315 notes_path: PathBuf::from("notes.txt"),
316 mcp_config_path: PathBuf::from("mcp.json"),
317 use_memory: false,
318 start_in_agent_mode: false,
319 skip_onboarding: true,
320 yolo: false,
321 resume_session_id: None,
322 initial_input: None,
323 };
324 let mut app = App::new(options, &Config::default());
325 app.ui_locale = crate::localization::Locale::En;
326 app.api_provider = crate::config::ApiProvider::Deepseek;
327 app
328 }
329
330 #[test]
331 fn test_help_unknown_command() {
332 let mut app = create_test_app();
333 let result = help(&mut app, Some("nonexistent"));
334 assert!(result.message.is_some());
335 assert!(result.message.unwrap().contains("Unknown command"));
336 assert!(result.action.is_none());
337 }
338
339 #[test]
340 fn test_help_known_command() {
341 let mut app = create_test_app();
342 let result = help(&mut app, Some("clear"));
343 assert!(result.message.is_some());
344 let msg = result.message.unwrap();
345 assert!(msg.contains("clear"));
346 assert!(msg.contains("Clear conversation history"));
347 assert!(msg.contains("Usage: /clear"));
348 }
349
350 #[test]
351 fn test_help_config_topic_uses_interactive_editor_text() {
352 let mut app = create_test_app();
353 let result = help(&mut app, Some("config"));
354 let msg = result.message.expect("help topic should return message");
355 assert!(msg.contains("config"));
356 assert!(msg.contains("Open interactive configuration editor"));
357 assert!(msg.contains("Usage: /config"));
358 }
359
360 #[test]
361 fn test_help_links_topic_shows_aliases() {
362 let mut app = create_test_app();
363 let result = help(&mut app, Some("links"));
364 let msg = result.message.expect("help topic should return message");
365 assert!(msg.contains("links"));
366 assert!(msg.contains("Show DeepSeek dashboard and docs links"));
367 assert!(msg.contains("Usage: /links"));
368 assert!(msg.contains("Aliases: dashboard, api"));
369 }
370
371 #[test]
372 fn test_help_memory_topic_shows_usage_and_description() {
373 let mut app = create_test_app();
374 let result = help(&mut app, Some("memory"));
375 let msg = result.message.expect("help topic should return message");
376 assert!(msg.contains("memory"));
377 assert!(msg.contains("persistent user-memory file"));
378 assert!(msg.contains("Usage: /memory [show|path|clear|edit|help]"));
379 }
380
381 #[test]
382 fn test_help_pushes_overlay() {
383 let mut app = create_test_app();
384 assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
385 let result = help(&mut app, None);
386 assert_eq!(result.message, None);
387 assert_eq!(result.action, None);
388 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
389 }
390
391 #[test]
392 fn test_help_does_not_duplicate_overlay() {
393 let mut app = create_test_app();
394 help(&mut app, None);
395 let initial_kind = app.view_stack.top_kind();
396 help(&mut app, None);
397 assert_eq!(app.view_stack.top_kind(), initial_kind);
398 }
399
400 #[test]
401 fn test_clear_resets_all_state() {
402 let mut app = create_test_app();
403 // Set up some state
404 app.history.push(HistoryCell::User {
405 content: "test".to_string(),
406 });
407 app.api_messages.push(Message {
408 role: "user".to_string(),
409 content: vec![],
410 });
411 app.session.total_conversation_tokens = 100;
412 app.tool_log.push("test".to_string());
413 app.current_session_id = Some("existing-session".to_string());
414
415 let result = clear(&mut app);
416 assert!(result.message.is_some());
417 assert!(app.history.is_empty());
418 assert!(app.api_messages.is_empty());
419 assert_eq!(app.session.total_conversation_tokens, 0);
420 assert!(app.tool_log.is_empty());
421 assert!(app.tool_cells.is_empty());
422 assert!(app.tool_details_by_cell.is_empty());
423 assert!(app.current_session_id.is_none());
424 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
425 }
426
427 #[test]
428 fn test_exit_returns_quit_action() {
429 let result = exit();
430 assert!(result.message.is_none());
431 assert!(matches!(result.action, Some(AppAction::Quit)));
432 }
433
434 #[test]
435 fn test_model_change_updates_state() {
436 let mut app = create_test_app();
437 let old_model = app.model.clone();
438 let result = model(&mut app, Some("deepseek-v4-flash"));
439 assert!(result.message.is_some());
440 let msg = result.message.unwrap();
441 assert!(msg.contains(&old_model));
442 assert!(msg.contains("deepseek-v4-flash"));
443 assert!(matches!(
444 result.action,
445 Some(AppAction::UpdateCompaction(_))
446 ));
447 assert_eq!(app.model, "deepseek-v4-flash");
448 assert_eq!(app.session.last_prompt_tokens, None);
449 assert_eq!(app.session.last_completion_tokens, None);
450 }
451
452 #[test]
453 fn test_model_auto_enables_auto_thinking() {
454 let mut app = create_test_app();
455 app.reasoning_effort = ReasoningEffort::Off;
456
457 let result = model(&mut app, Some("auto"));
458
459 assert!(result.message.is_some());
460 assert!(app.auto_model);
461 assert_eq!(app.model, "auto");
462 assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
463 assert!(app.last_effective_model.is_none());
464 assert!(app.last_effective_reasoning_effort.is_none());
465 }
466
467 #[test]
468 fn test_model_change_accepts_future_deepseek_model() {
469 let mut app = create_test_app();
470 let result = model(&mut app, Some("deepseek-v4"));
471 assert!(result.message.is_some());
472 let msg = result.message.unwrap();
473 assert!(msg.contains("deepseek-v4"));
474 assert_eq!(app.model, "deepseek-v4");
475 assert!(matches!(
476 result.action,
477 Some(AppAction::UpdateCompaction(_))
478 ));
479 }
480
481 #[test]
482 fn test_model_change_rejects_invalid_model() {
483 let mut app = create_test_app();
484 let result = model(&mut app, Some("gpt-4"));
485 assert!(result.message.is_some());
486 let msg = result.message.unwrap();
487 assert!(msg.contains("Invalid model"));
488 assert!(msg.contains("DeepSeek model ID"));
489 assert!(msg.contains("deepseek-v4-pro"));
490 assert!(msg.contains("deepseek-v4-flash"));
491 assert!(result.action.is_none());
492 }
493
494 #[test]
495 fn test_model_without_args_opens_picker() {
496 let mut app = create_test_app();
497 let result = model(&mut app, None);
498 assert_eq!(result.message, None);
499 assert_eq!(result.action, Some(AppAction::OpenModelPicker));
500 }
501
502 #[test]
503 fn test_models_triggers_fetch_action() {
504 let mut app = create_test_app();
505 let result = models(&mut app);
506 assert!(result.message.is_none());
507 assert!(matches!(result.action, Some(AppAction::FetchModels)));
508 }
509
510 #[test]
511 fn test_subagents_pushes_view_and_sets_status() {
512 let mut app = create_test_app();
513 let result = subagents(&mut app);
514 assert!(result.message.is_none());
515 assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
516 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
517 assert_eq!(
518 app.status_message,
519 Some("Fetching sub-agent status...".to_string())
520 );
521 }
522
523 #[test]
524 fn test_deepseek_links() {
525 let mut app = create_test_app();
526 let result = deepseek_links(&mut app);
527 assert!(result.message.is_some());
528 let msg = result.message.unwrap();
529 assert!(msg.contains("DeepSeek Links"));
530 assert!(msg.contains("https://platform.deepseek.com"));
531 assert!(result.action.is_none());
532 }
533
534 #[test]
535 fn test_home_dashboard_includes_all_sections() {
536 let mut app = create_test_app();
537 app.session.total_conversation_tokens = 1234;
538 let result = home_dashboard(&mut app);
539 assert!(result.message.is_some());
540 let msg = result.message.unwrap();
541 assert!(msg.contains("DeepSeek TUI Home Dashboard"));
542 assert!(msg.contains("Model:"));
543 assert!(msg.contains("Mode:"));
544 assert!(msg.contains("Workspace:"));
545 assert!(msg.contains("History:"));
546 assert!(msg.contains("Tokens:"));
547 assert!(msg.contains("Quick Actions"));
548 assert!(msg.contains("Mode Tips"));
549 assert!(result.action.is_none());
550 }
551
552 #[test]
553 fn test_home_dashboard_shows_queued_when_present() {
554 let mut app = create_test_app();
555 app.queued_messages
556 .push_back(crate::tui::app::QueuedMessage::new(
557 "test".to_string(),
558 None,
559 ));
560 let result = home_dashboard(&mut app);
561 let msg = result.message.unwrap();
562 assert!(msg.contains("Queued:"));
563 }
564
565 #[test]
566 fn test_home_dashboard_mode_tips_for_each_mode() {
567 let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan];
568 for mode in modes {
569 let mut app = create_test_app();
570 app.mode = mode;
571 let result = home_dashboard(&mut app);
572 let msg = result.message.unwrap();
573 assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
574 }
575 }
576
577 #[test]
578 fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() {
579 let mut app = create_test_app();
580 let result = home_dashboard(&mut app);
581 let msg = result
582 .message
583 .expect("home dashboard should return message");
584 assert!(msg.contains("/links - Dashboard & API links"));
585 assert!(msg.contains("/config - Open interactive configuration editor"));
586 assert!(
587 !msg.lines()
588 .any(|line| line.trim_start().starts_with("/set "))
589 );
590 assert!(!msg.contains("/deepseek"));
591 }
592
593 #[test]
594 fn home_dashboard_localizes_in_zh_hans() {
595 use crate::localization::Locale;
596 let mut app = create_test_app();
597 app.ui_locale = Locale::ZhHans;
598 let result = home_dashboard(&mut app);
599 let msg = result
600 .message
601 .expect("home dashboard should return message");
602 assert!(msg.contains("主面板"), "missing zh-Hans title:\n{msg}");
603 assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}");
604 assert!(
605 msg.contains("快捷操作"),
606 "missing zh-Hans quick actions:\n{msg}"
607 );
608 assert!(
609 msg.contains("模式提示"),
610 "missing zh-Hans mode tips:\n{msg}"
611 );
612 }
613 }
614
614 lines RUST