返回 CodeWhale
core.rs
根目录 / crates / tui / src / commands / groups / core / core.rs
1 //! Core commands: help, clear, exit, model
2
3 use std::fmt::Write;
4 use std::path::PathBuf;
5
6 use crate::config::{
7 ApiProvider, DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL,
8 normalize_custom_model_id, normalize_model_name_for_provider,
9 };
10 use crate::localization::{Locale, MessageId, tr};
11 #[cfg(test)]
12 use crate::tui::app::ReasoningEffort;
13 use crate::tui::app::{App, AppAction, AppMode};
14 use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents};
15
16 use super::CommandResult;
17
18 /// Show help information
19 pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult {
20 if let Some(topic) = topic {
21 let user_commands = crate::commands::user_registry::with_registry_for_workspace(
22 Some(&app.workspace),
23 Clone::clone,
24 );
25 if let Some(command) = user_commands.get(topic) {
26 return CommandResult::message(user_command_help(app.ui_locale, command));
27 }
28
29 // Show help for specific command
30 if let Some(cmd) = crate::commands::get_command_info(topic) {
31 let mut help = format!(
32 "{}\n\n {}\n\n {} {}",
33 cmd.name,
34 cmd.description_for(app.ui_locale),
35 tr(app.ui_locale, MessageId::HelpUsageLabel),
36 cmd.usage
37 );
38 let visible_aliases = cmd
39 .aliases
40 .iter()
41 .filter(|alias| user_commands.get(alias).is_none())
42 .copied()
43 .collect::<Vec<_>>();
44 if !visible_aliases.is_empty() {
45 let _ = write!(
46 help,
47 "\n {} {}",
48 tr(app.ui_locale, MessageId::HelpAliasesLabel),
49 visible_aliases.join(", ")
50 );
51 }
52 if cmd.name == "config" {
53 help.push_str(
54 "\n\n Provider context window: set `context_window = 262144` under the active `[providers.<name>]` table to cap a 1M model to 256K. Use `/config context_window` to inspect the configured and effective values.",
55 );
56 }
57 return CommandResult::message(help);
58 }
59
60 // Skills are user-invocable but were never a `/help` topic (#3912):
61 // they execute and autocomplete, yet the surface that teaches the
62 // product claimed they did not exist.
63 if let Some(help) = skill_help(app, topic) {
64 return CommandResult::message(help);
65 }
66
67 return CommandResult::error(
68 tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic),
69 );
70 }
71
72 // Show help overlay
73 if app.view_stack.top_kind() != Some(ModalKind::Help) {
74 let help = HelpView::new_for_workspace(app.ui_locale, &app.workspace, &app.cached_skills);
75 app.view_stack.push(help);
76 }
77 CommandResult::ok()
78 }
79
80 /// `/help <skill>` for a discovered skill (#3912).
81 ///
82 /// Matches the cached skill list case-insensitively, accepting the bare name
83 /// as well as either invocation shape the dispatcher supports, so `/help
84 /// $review` and `/help /skill review` resolve the same as `/help review`.
85 fn skill_help(app: &App, topic: &str) -> Option<String> {
86 let needle = topic
87 .trim()
88 .trim_start_matches('$')
89 .trim_start_matches('/')
90 .trim_start_matches("skill ")
91 .trim();
92 if needle.is_empty() {
93 return None;
94 }
95
96 let (name, description) = app
97 .cached_skills
98 .iter()
99 .find(|(name, _)| name.eq_ignore_ascii_case(needle))?;
100
101 let mut help = format!("${name}");
102 if !description.trim().is_empty() {
103 let _ = write!(help, "\n\n {}", description.trim());
104 }
105 // Both shapes are advertised because both dispatch (`commands::execute`).
106 let _ = write!(
107 help,
108 "\n\n {} ${name}\n {} /skill {name}",
109 tr(app.ui_locale, MessageId::HelpUsageLabel),
110 tr(app.ui_locale, MessageId::HelpUsageLabel),
111 );
112 Some(help)
113 }
114
115 fn user_command_help(
116 locale: Locale,
117 command: &crate::commands::user_registry::UserCommandMetadata,
118 ) -> String {
119 let mut help = command.name.clone();
120 if let Some(description) = command
121 .description
122 .as_deref()
123 .filter(|description| !description.trim().is_empty())
124 {
125 let _ = write!(help, "\n\n {description}");
126 }
127
128 let usage = command
129 .display_usage()
130 .map(str::to_owned)
131 .unwrap_or_else(|| format!("/{}", command.name));
132 let _ = write!(
133 help,
134 "\n\n {} {}",
135 tr(locale, MessageId::HelpUsageLabel),
136 usage
137 );
138 if !command.aliases.is_empty() {
139 let _ = write!(
140 help,
141 "\n {} {}",
142 tr(locale, MessageId::HelpAliasesLabel),
143 command.aliases.join(", ")
144 );
145 }
146 help
147 }
148
149 /// Clear conversation history
150 pub fn clear(app: &mut App) -> CommandResult {
151 if app.session_transition_blocked() {
152 return CommandResult::error(
153 tr(app.ui_locale, MessageId::ClearConversationBusy).to_string(),
154 );
155 }
156 if !reset_conversation_state(app) {
157 return CommandResult::error(
158 tr(app.ui_locale, MessageId::ClearConversationBusy).to_string(),
159 );
160 }
161 app.current_session_id = None;
162 app.current_session_metadata = None;
163 app.session_title = None;
164 let locale = app.ui_locale;
165 let message = tr(locale, MessageId::ClearConversation).to_string();
166 CommandResult::with_message_and_action(
167 message,
168 AppAction::SyncSession {
169 session_id: None,
170 messages: Vec::new(),
171 system_prompt: None,
172 model: app.model.clone(),
173 workspace: app.workspace.clone(),
174 mode: app.mode,
175 },
176 )
177 }
178
179 /// Reset the active conversation without choosing the next session id.
180 pub(crate) fn reset_conversation_state(app: &mut App) -> bool {
181 // Work state is the only contended portion. Acquire and clear it first so
182 // `/clear` and `/new` are all-or-nothing rather than losing conversation
183 // state while leaving an old To-do attached to the next session.
184 if !app.clear_todos() {
185 return false;
186 }
187 // Atomically retire background accounting before zeroing the session.
188 // Late reports retain the old scope token and are discarded instead of
189 // appearing in the new conversation.
190 let _settled_old_cost_scope = crate::cost_status::close_current_scope();
191 app.clear_history();
192 app.mark_history_updated();
193 app.api_messages.clear();
194 app.system_prompt = None;
195 app.viewport.transcript_selection.clear();
196 app.queued_messages.clear();
197 app.queued_draft = None;
198 app.session.total_tokens = 0;
199 app.session.total_conversation_tokens = 0;
200 app.session.reset_token_breakdown();
201 app.session.session_cost = 0.0;
202 app.session.session_cost_cny = 0.0;
203 app.session.subagent_cost = 0.0;
204 app.session.subagent_cost_cny = 0.0;
205 app.session.subagent_cost_event_seqs.clear();
206 app.session.displayed_cost_high_water = 0.0;
207 app.session.displayed_cost_high_water_cny = 0.0;
208 app.reset_cost_coverage();
209 app.tool_log.clear();
210 app.tool_cells.clear();
211 app.tool_details_by_cell.clear();
212 app.exploring_entries.clear();
213 app.ignored_tool_calls.clear();
214 app.pending_tool_uses.clear();
215 app.last_exec_wait_command = None;
216 app.session.last_prompt_tokens = None;
217 app.session.last_completion_tokens = None;
218 app.session.last_output_throughput = None;
219 app.session.last_prompt_cache_hit_tokens = None;
220 app.session.last_prompt_cache_miss_tokens = None;
221 app.session.last_reasoning_replay_tokens = None;
222 app.session.turn_cache_history.clear();
223 app.session.last_cache_inspection = None;
224 app.session.last_warmup_key = None;
225 app.session.last_tool_catalog = None;
226 app.session.last_base_url = None;
227 true
228 }
229
230 /// Exit the application
231 pub fn exit() -> CommandResult {
232 CommandResult::action(AppAction::Quit)
233 }
234
235 /// Switch or view current model. With no argument, open the two-pane
236 /// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
237 /// way to flip both knobs without memorising the docs.
238 pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
239 if model_name.is_some_and(|name| name.eq_ignore_ascii_case("save-default")) {
240 // Explicit persistence of the pending session route as the startup
241 // default — only an explicit command can write settings after an
242 // in-session route change.
243 let message = app.apply_route_save_choice(
244 crate::tui::views::route_save_prompt::RouteSaveChoice::SaveAsDefault,
245 );
246 return CommandResult::message(message);
247 }
248 if let Some(name) = model_name {
249 // Manual Models.dev catalog refresh (#4187). Dispatched async so the
250 // TUI event loop is not blocked; failure keeps prior/bundled rows.
251 if name.trim().eq_ignore_ascii_case("refresh") {
252 return CommandResult::action(AppAction::RefreshModelsDevCatalog);
253 }
254 if name.trim().eq_ignore_ascii_case("auto") {
255 let old_model = app.model_display_label();
256 let model_changed = !app.auto_model || app.model != "auto";
257 app.set_model_selection("auto".to_string());
258 app.active_route_limits = app.context_window_override_limits();
259 app.update_model_compaction_budget();
260 if model_changed {
261 app.clear_model_scoped_telemetry();
262 } else {
263 app.session.last_prompt_tokens = None;
264 app.session.last_completion_tokens = None;
265 app.session.last_output_throughput = None;
266 }
267 let provider_identity = app.provider_identity_for_persistence().to_string();
268 app.provider_models
269 .insert(provider_identity.clone(), "auto".to_string());
270 // Temporary by default; the route-save prompt owns persistence.
271 app.note_session_route_change(&provider_identity, "auto");
272 let mut message = tr(app.ui_locale, MessageId::ModelChanged)
273 .replace("{old}", &old_model)
274 .replace("{new}", "auto");
275 message.push_str(
276 " (session only — /fleet save updates this Fleet, /fleet save-as saves a new Fleet, /model save-default remembers the default)",
277 );
278 return CommandResult::with_message_and_action(
279 message,
280 AppAction::UpdateCompaction(app.compaction_config()),
281 );
282 }
283 let model_id = if app.accepts_custom_model_ids() {
284 let Some(model_id) = normalize_custom_model_id(name) else {
285 return CommandResult::error(format!(
286 "Invalid model '{name}'. Expected a non-empty model ID."
287 ));
288 };
289 model_id
290 } else {
291 let Some(model_id) = normalize_model_name_for_provider(app.api_provider, name) else {
292 return CommandResult::error(format!(
293 "Invalid model '{name}'. Expected auto or a model for the active provider ({}).",
294 app.api_provider.as_str()
295 ));
296 };
297 model_id
298 };
299 let strict_direct_custom_endpoint = app.accepts_custom_model_ids()
300 && matches!(
301 app.api_provider,
302 ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Zai
303 );
304 let route_resolution = if strict_direct_custom_endpoint {
305 None
306 } else {
307 // `/model` normally resolves against the active provider's
308 // catalog-default endpoint so it retains the existing local
309 // provider/model validation. The one endpoint-sensitive model
310 // selection is Kimi Code's bare `k3`: resolve it against the
311 // committed Kimi Code route rather than silently falling back to
312 // Moonshot's direct API route. Do not pass an unrelated stale
313 // endpoint through here; doing so would turn a foreign provider
314 // model into an apparent custom-route selection.
315 let route_base_url = crate::config::is_exact_kimi_code_k3_route(
316 app.api_provider,
317 &app.active_route_base_url,
318 &model_id,
319 )
320 .then(|| app.active_route_base_url.clone());
321 match crate::route_runtime::resolve_route_candidate_with_context_metadata(
322 app.api_provider,
323 Some(&model_id),
324 None,
325 route_base_url,
326 app.active_context_window_override,
327 None,
328 ) {
329 Ok(resolution) => Some(resolution),
330 Err(reason) => return CommandResult::error(reason),
331 }
332 };
333 let old_model = app.model_display_label();
334 let model_changed = app.auto_model || app.model != model_id;
335 app.set_model_selection(model_id.clone());
336 if let Some(resolution) = route_resolution {
337 app.set_active_route_resolution(
338 resolution.candidate.endpoint().base_url.clone(),
339 resolution.candidate.limits(),
340 resolution.context_window.source,
341 );
342 } else {
343 app.active_route_limits = app.context_window_override_limits();
344 app.active_context_window_source = if app.active_context_window_override.is_some() {
345 crate::route_runtime::ContextWindowSource::Configured
346 } else {
347 crate::route_runtime::ContextWindowSource::Fallback
348 };
349 }
350 app.update_model_compaction_budget();
351 if model_changed {
352 app.clear_model_scoped_telemetry();
353 } else {
354 app.session.last_prompt_tokens = None;
355 app.session.last_completion_tokens = None;
356 app.session.last_output_throughput = None;
357 }
358 let provider_identity = app.provider_identity_for_persistence().to_string();
359 app.provider_models
360 .insert(provider_identity.clone(), model_id.clone());
361 app.enable_provider_model(&provider_identity, &model_id);
362 // Route changes are temporary by default: nothing is written here.
363 // The route-save prompt offers the explicit persistence choices.
364 app.note_session_route_change(&provider_identity, &model_id);
365 let mut message = tr(app.ui_locale, MessageId::ModelChanged)
366 .replace("{old}", &old_model)
367 .replace("{new}", &model_id);
368 message.push_str(
369 " (session only — /fleet save updates this Fleet, /fleet save-as saves a new Fleet, /model save-default remembers the default)",
370 );
371 CommandResult::with_message_and_action(
372 message,
373 AppAction::UpdateCompaction(app.compaction_config()),
374 )
375 } else {
376 CommandResult::action(AppAction::OpenModelPicker)
377 }
378 }
379
380 /// Fetch and list available models from the configured API endpoint.
381 pub fn models(_app: &mut App) -> CommandResult {
382 CommandResult::action(AppAction::FetchModels)
383 }
384
385 /// List Fleet worker status from the engine.
386 pub fn subagents(app: &mut App) -> CommandResult {
387 if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
388 let agents = subagent_view_agents(app, &app.subagent_cache);
389 app.view_stack.push(SubAgentsView::new(agents));
390 }
391 app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string());
392 CommandResult::action(AppAction::ListSubAgents)
393 }
394
395 /// Switch to a configured profile.
396 pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult {
397 let profile_name = match arg {
398 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
399 _ => {
400 return CommandResult::error(
401 "Usage: /profile <name>\n\nSwitch to a named config profile. Profiles are defined in ~/.codewhale/config.toml under [profiles] sections.",
402 );
403 }
404 };
405 CommandResult::with_message_and_action(
406 format!("Switching to profile '{profile_name}'..."),
407 AppAction::SwitchProfile {
408 profile: profile_name,
409 },
410 )
411 }
412
413 pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult {
414 let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else {
415 return CommandResult::message(format!("Current workspace: {}", app.workspace.display()));
416 };
417
418 let expanded = match expand_workspace_path(raw_path) {
419 Ok(path) => path,
420 Err(message) => return CommandResult::error(message),
421 };
422 let candidate = if expanded.is_absolute() {
423 expanded
424 } else {
425 app.workspace.join(expanded)
426 };
427
428 if !candidate.exists() {
429 return CommandResult::error(format!("Workspace does not exist: {}", candidate.display()));
430 }
431 if !candidate.is_dir() {
432 return CommandResult::error(format!(
433 "Workspace is not a directory: {}",
434 candidate.display()
435 ));
436 }
437
438 let workspace = candidate.canonicalize().unwrap_or(candidate);
439 CommandResult::with_message_and_action(
440 format!("Switching workspace to {}...", workspace.display()),
441 AppAction::SwitchWorkspace { workspace },
442 )
443 }
444
445 fn expand_workspace_path(path: &str) -> Result<PathBuf, String> {
446 if path == "~" {
447 return crate::config::effective_home_dir()
448 .ok_or_else(|| "Could not resolve home directory".to_string());
449 }
450 if let Some(rest) = path.strip_prefix("~/") {
451 let home = crate::config::effective_home_dir()
452 .ok_or_else(|| "Could not resolve home directory".to_string())?;
453 return Ok(home.join(rest));
454 }
455 Ok(PathBuf::from(path))
456 }
457
458 fn public_site_locale_segment(locale: Locale) -> &'static str {
459 match locale {
460 Locale::ZhHans | Locale::ZhHant => "zh",
461 Locale::Ja => "ja",
462 Locale::Vi => "vi",
463 Locale::Ko => "ko",
464 Locale::Es419 => "es",
465 Locale::PtBr => "pt-BR",
466 Locale::Ru => "ru",
467 Locale::Uk => "uk",
468 // Not shipped on the website yet — English pages are the fallback.
469 Locale::En | Locale::Ca | Locale::De | Locale::Fr | Locale::Id | Locale::Hi => "en",
470 }
471 }
472
473 /// Show Codewhale documentation, community, managed-app, and provider links.
474 pub fn codewhale_links(app: &mut App) -> CommandResult {
475 let locale = app.ui_locale;
476 let active_provider = app.api_provider.as_str();
477 let site_locale = public_site_locale_segment(locale);
478 let mut message = format!(
479 "{}\n─────────────────────────────\n",
480 tr(locale, MessageId::LinksProjectTitle)
481 );
482
483 let _ = writeln!(
484 message,
485 "{} `https://codewhale.net/{site_locale}/docs`",
486 tr(locale, MessageId::LinksDocumentation)
487 );
488 let _ = writeln!(
489 message,
490 "{} `https://codewhale.net/{site_locale}/community`",
491 tr(locale, MessageId::LinksCommunity)
492 );
493 let _ = writeln!(
494 message,
495 "{} `https://github.com/Hmbown/CodeWhale`",
496 tr(locale, MessageId::LinksGitHub)
497 );
498 let _ = writeln!(
499 message,
500 "{} `https://app.codewhale.net`",
501 tr(locale, MessageId::LinksManagedApp)
502 );
503 let _ = writeln!(message, "{}", tr(locale, MessageId::LinksManagedAppNote));
504
505 let _ = write!(
506 message,
507 "\n{}\n─────────────────────────────\n",
508 tr(locale, MessageId::LinksTitle)
509 );
510
511 for provider in codewhale_config::provider::providers_sorted_for_display() {
512 let links = provider.credential_help();
513 let active_marker = if provider.id() == active_provider {
514 " <- current"
515 } else {
516 ""
517 };
518 let _ = writeln!(
519 message,
520 "\n{} ({}){}",
521 provider.display_name(),
522 provider.id(),
523 active_marker
524 );
525 if let Some(key_url) = links.credential_url {
526 let _ = writeln!(
527 message,
528 "{} `{}`",
529 tr(locale, MessageId::LinksDashboard),
530 key_url
531 );
532 } else {
533 let _ = writeln!(
534 message,
535 "{} {}",
536 tr(locale, MessageId::LinksDashboard),
537 links.guidance
538 );
539 }
540 if let Some(docs_url) = links.docs_url {
541 let _ = writeln!(
542 message,
543 "{} `{}`",
544 tr(locale, MessageId::LinksDocs),
545 docs_url
546 );
547 }
548 if provider.kind() == codewhale_config::ProviderKind::Moonshot {
549 let _ = writeln!(
550 message,
551 "{}",
552 tr(locale, MessageId::LinksKimiCodeRouteNote)
553 .replace("{route}", DEFAULT_KIMI_CODE_BASE_URL)
554 .replace("{console}", KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL)
555 );
556 }
557 let env_vars = provider.env_vars();
558 if env_vars.is_empty() {
559 let _ = writeln!(message, "Env: none");
560 } else {
561 let _ = writeln!(message, "Env: {}", env_vars.join(", "));
562 }
563 }
564
565 let _ = writeln!(message, "\n{}", tr(locale, MessageId::LinksTip));
566 CommandResult::message(message)
567 }
568
569 /// Show home dashboard with stats and quick actions
570 pub fn home_dashboard(app: &mut App) -> CommandResult {
571 let locale = app.ui_locale;
572 let mut stats = String::new();
573
574 // Basic info
575 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeDashboardTitle));
576 let _ = writeln!(stats, "============================================");
577
578 // Model & mode
579 let _ = writeln!(
580 stats,
581 "{} {}",
582 tr(locale, MessageId::HomeModel),
583 app.model
584 );
585 let _ = writeln!(
586 stats,
587 "{} {}",
588 tr(locale, MessageId::HomeMode),
589 app.mode.label()
590 );
591 let _ = writeln!(
592 stats,
593 "{} {}",
594 tr(locale, MessageId::HomeWorkspace),
595 app.workspace.display()
596 );
597
598 // Session stats
599 let history_count = app.history.len();
600 let total_tokens = app.session.total_conversation_tokens;
601 let queued_messages = app.queued_messages.len();
602 let _ = writeln!(
603 stats,
604 "{} {} messages",
605 tr(locale, MessageId::HomeHistory),
606 history_count
607 );
608 let _ = writeln!(
609 stats,
610 "{} {} (session)",
611 tr(locale, MessageId::HomeTokens),
612 total_tokens
613 );
614 if queued_messages > 0 {
615 let _ = writeln!(
616 stats,
617 "{} {} messages",
618 tr(locale, MessageId::HomeQueued),
619 queued_messages
620 );
621 }
622
623 // Fleet role workers
624 let subagent_count = app.subagent_cache.len();
625 if subagent_count > 0 {
626 let _ = writeln!(
627 stats,
628 "{} {} active",
629 tr(locale, MessageId::HomeSubagents),
630 subagent_count
631 );
632 }
633
634 // Active skill
635 if let Some(skill) = &app.active_skill {
636 let _ = writeln!(
637 stats,
638 "{} {} (active)",
639 tr(locale, MessageId::HomeSkill),
640 skill
641 );
642 }
643
644 // Quick actions section
645 let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeQuickActions));
646 let _ = writeln!(stats, "--------------------------------------------");
647 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickLinks));
648 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSkills));
649 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickConfig));
650 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSettings));
651 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickModel));
652 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSubagents));
653 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTaskList));
654 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickHelp));
655
656 // Mode-specific tips
657 let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeModeTips));
658 let _ = writeln!(stats, "--------------------------------------------");
659 match app.mode {
660 AppMode::Agent | AppMode::Auto => {
661 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeTip));
662 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeReviewTip));
663 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeYoloTip));
664 }
665 AppMode::Yolo => {
666 // Compatibility residual: YOLO is invisible Act + Full Access.
667 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeTip));
668 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeCaution));
669 }
670 AppMode::Operate => {
671 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeOperateModeTip));
672 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeOperateModeFleetTip));
673 }
674 AppMode::Plan => {
675 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip));
676 let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip));
677 }
678 }
679
680 CommandResult::message(stats)
681 }
682
683 /// Toggle output translation to the current system language on/off.
684 ///
685 /// When enabled, the model is instructed to respond in the current locale and an
686 /// interception layer translates any remaining English output before it
687 /// reaches the user.
688 pub fn translate(app: &mut App) -> CommandResult {
689 app.translation_enabled = !app.translation_enabled;
690 let locale = app.ui_locale;
691 if app.translation_enabled {
692 CommandResult::message(tr(locale, MessageId::CmdTranslateOn))
693 } else {
694 CommandResult::message(tr(locale, MessageId::CmdTranslateOff))
695 }
696 }
697
698 #[cfg(test)]
699 mod tests {
700 use super::*;
701 use crate::client::PromptInspection;
702 use crate::config::Config;
703 use crate::models::Message;
704 use crate::tui::app::{App, AppMode, TuiOptions, TurnCacheRecord};
705 use crate::tui::history::HistoryCell;
706 use std::ffi::OsString;
707 use std::path::PathBuf;
708 use std::time::Instant;
709 use tempfile::{TempDir, tempdir};
710
711 #[test]
712 fn help_topic_resolves_a_discovered_skill_and_both_shapes() {
713 // #3912: `/help <skill>` said "Unknown command" for a skill that
714 // executes and autocompletes.
715 let mut app = create_test_app();
716 app.cached_skills = vec![(
717 "codereview".to_string(),
718 "Review a diff for defects".to_string(),
719 )];
720
721 for topic in [
722 "codereview",
723 "CodeReview",
724 "$codereview",
725 "/skill codereview",
726 ] {
727 let result = help(&mut app, Some(topic));
728 let message = result
729 .message
730 .as_deref()
731 .unwrap_or_else(|| panic!("{topic} should resolve to skill help"));
732 assert!(message.contains("Review a diff for defects"), "{message}");
733 assert!(message.contains("$codereview"), "{message}");
734 assert!(message.contains("/skill codereview"), "{message}");
735 }
736 }
737
738 #[test]
739 fn help_topic_still_errors_for_an_unknown_name() {
740 let mut app = create_test_app();
741 app.cached_skills = vec![("codereview".to_string(), "Review a diff".to_string())];
742 let result = help(&mut app, Some("definitely-not-a-thing"));
743 assert!(result.is_error, "unknown topics must still be an error");
744 assert!(
745 !result
746 .message
747 .as_deref()
748 .unwrap_or_default()
749 .contains("Review a diff"),
750 "the skill fallback must not match an unrelated topic"
751 );
752 }
753
754 struct SettingsPathGuard {
755 _tmp: TempDir,
756 previous: Option<OsString>,
757 _lock: crate::test_support::TestEnvLock,
758 }
759
760 impl SettingsPathGuard {
761 fn new() -> Self {
762 let lock = crate::test_support::lock_test_env();
763 let tmp = TempDir::new().expect("settings tempdir");
764 let config_path = tmp.path().join(".deepseek").join("config.toml");
765 std::fs::create_dir_all(config_path.parent().expect("config parent"))
766 .expect("config dir");
767 let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH");
768 // Safety: test-only environment mutation guarded by a global mutex.
769 unsafe {
770 std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path);
771 }
772 Self {
773 _tmp: tmp,
774 previous,
775 _lock: lock,
776 }
777 }
778 }
779
780 impl Drop for SettingsPathGuard {
781 fn drop(&mut self) {
782 // Safety: test-only environment mutation guarded by a global mutex.
783 unsafe {
784 if let Some(previous) = self.previous.take() {
785 std::env::set_var("DEEPSEEK_CONFIG_PATH", previous);
786 } else {
787 std::env::remove_var("DEEPSEEK_CONFIG_PATH");
788 }
789 }
790 }
791 }
792
793 fn create_test_app() -> App {
794 let options = TuiOptions {
795 skills_dir: PathBuf::from("/tmp/test-skills"),
796 ..crate::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace"))
797 };
798 let mut app = App::new(options, &Config::default());
799 app.ui_locale = crate::localization::Locale::En;
800 app.api_provider = crate::config::ApiProvider::Deepseek;
801 app.model = "deepseek-v4-pro".to_string();
802 app.auto_model = false;
803 app.model_ids_passthrough = false;
804 app
805 }
806
807 #[test]
808 fn test_help_unknown_command() {
809 let mut app = create_test_app();
810 let result = help(&mut app, Some("nonexistent"));
811 assert!(result.message.is_some());
812 assert!(result.message.unwrap().contains("Unknown command"));
813 assert!(result.action.is_none());
814 }
815
816 #[test]
817 fn test_help_known_command() {
818 let mut app = create_test_app();
819 let result = help(&mut app, Some("clear"));
820 assert!(result.message.is_some());
821 let msg = result.message.unwrap();
822 assert!(msg.contains("clear"));
823 assert!(msg.contains("Clear conversation history"));
824 assert!(msg.contains("Usage: /clear"));
825 }
826
827 #[test]
828 fn test_help_config_topic_uses_interactive_editor_text() {
829 let mut app = create_test_app();
830 let result = help(&mut app, Some("config"));
831 let msg = result.message.expect("help topic should return message");
832 assert!(msg.contains("config"));
833 assert!(msg.contains("Inspect and change settings"));
834 assert!(msg.contains("Usage: /config"));
835 assert!(msg.contains("context_window = 262144"));
836 assert!(msg.contains("/config context_window"));
837 }
838
839 #[test]
840 fn test_help_links_topic_shows_aliases() {
841 let mut app = create_test_app();
842 let result = help(&mut app, Some("links"));
843 let msg = result.message.expect("help topic should return message");
844 assert!(msg.contains("links"));
845 assert!(msg.contains("Show Codewhale, community, and provider links"));
846 assert!(msg.contains("Usage: /links"));
847 assert!(msg.contains("Aliases: dashboard, api"));
848 }
849
850 #[test]
851 fn test_help_memory_topic_shows_usage_and_description() {
852 let mut app = create_test_app();
853 let result = help(&mut app, Some("memory"));
854 let msg = result.message.expect("help topic should return message");
855 assert!(msg.contains("memory"));
856 assert!(msg.contains("persistent user-memory file"));
857 assert!(msg.contains("Usage: /memory [show|path|clear|edit|help]"));
858 }
859
860 #[test]
861 fn test_help_pushes_overlay() {
862 let mut app = create_test_app();
863 assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
864 let result = help(&mut app, None);
865 assert_eq!(result.message, None);
866 assert_eq!(result.action, None);
867 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
868 }
869
870 #[test]
871 fn test_help_does_not_duplicate_overlay() {
872 let mut app = create_test_app();
873 help(&mut app, None);
874 let initial_kind = app.view_stack.top_kind();
875 help(&mut app, None);
876 assert_eq!(app.view_stack.top_kind(), initial_kind);
877 }
878
879 #[test]
880 fn test_clear_resets_all_state() {
881 let mut app = create_test_app();
882 // Set up some state
883 app.history.push(HistoryCell::User {
884 content: "test".to_string(),
885 });
886 app.api_messages.push(Message {
887 role: "user".to_string(),
888 content: vec![],
889 });
890 app.session.total_conversation_tokens = 100;
891 app.tool_log.push("test".to_string());
892 app.current_session_id = Some("existing-session".to_string());
893 app.session_artifacts
894 .push(crate::artifacts::ArtifactRecord {
895 id: "art_call_big".to_string(),
896 kind: crate::artifacts::ArtifactKind::ToolOutput,
897 session_id: "existing-session".to_string(),
898 tool_call_id: "call-big".to_string(),
899 tool_name: "exec_shell".to_string(),
900 created_at: chrono::Utc::now(),
901 byte_size: 128,
902 preview: "tool output".to_string(),
903 storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"),
904 });
905
906 let result = clear(&mut app);
907 assert!(result.message.is_some());
908 assert!(app.history.is_empty());
909 assert!(app.api_messages.is_empty());
910 assert_eq!(app.session.total_conversation_tokens, 0);
911 assert!(app.tool_log.is_empty());
912 assert!(app.tool_cells.is_empty());
913 assert!(app.tool_details_by_cell.is_empty());
914 assert!(app.session_artifacts.is_empty());
915 assert!(app.current_session_id.is_none());
916 assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
917 }
918
919 #[test]
920 fn clear_is_all_or_nothing_when_work_state_is_busy() {
921 let mut app = create_test_app();
922 app.history.push(HistoryCell::User {
923 content: "keep me".to_string(),
924 });
925 app.api_messages.push(Message {
926 role: "user".to_string(),
927 content: vec![],
928 });
929 app.current_session_id = Some("current-session".to_string());
930 let plan_state = app.plan_state.clone();
931 let _held = plan_state.try_lock().expect("hold plan lock");
932
933 let result = clear(&mut app);
934
935 assert!(result.is_error);
936 assert!(result.action.is_none());
937 assert_eq!(app.history.len(), 1);
938 assert_eq!(app.api_messages.len(), 1);
939 assert_eq!(app.current_session_id.as_deref(), Some("current-session"));
940 assert!(result.message.as_deref().is_some_and(|message| {
941 message.contains("Nothing cleared") && message.contains("busy")
942 }));
943 }
944
945 #[test]
946 fn clear_rejects_an_active_turn_without_mutating_session_state() {
947 let mut app = create_test_app();
948 app.history.push(HistoryCell::User {
949 content: "keep active turn".to_string(),
950 });
951 app.api_messages.push(Message {
952 role: "user".to_string(),
953 content: vec![],
954 });
955 app.current_session_id = Some("active-session".to_string());
956 app.is_loading = true;
957 app.runtime_turn_status = Some("in_progress".to_string());
958
959 let result = clear(&mut app);
960
961 assert!(result.is_error);
962 assert!(result.action.is_none());
963 assert_eq!(app.history.len(), 1);
964 assert_eq!(app.api_messages.len(), 1);
965 assert_eq!(app.current_session_id.as_deref(), Some("active-session"));
966 }
967
968 #[test]
969 fn clear_resets_session_telemetry() {
970 let mut app = create_test_app();
971 app.session.total_tokens = 234;
972 app.session.total_conversation_tokens = 123;
973 app.session.session_cost = 0.42;
974 app.session.session_cost_cny = 3.05;
975 app.session.subagent_cost = 0.11;
976 app.session.subagent_cost_cny = 0.80;
977 app.session
978 .subagent_cost_event_seqs
979 .insert(("turn-test".to_string(), 7));
980 app.session.displayed_cost_high_water = 0.53;
981 app.session.displayed_cost_high_water_cny = 3.85;
982 app.session.last_prompt_cache_hit_tokens = Some(70);
983 app.session.last_prompt_cache_miss_tokens = Some(30);
984 app.session.last_reasoning_replay_tokens = Some(12);
985 app.session.total_cache_write_tokens = 99;
986 app.session.last_warmup_key = None;
987 app.session.last_tool_catalog = Some(Vec::new());
988 app.session.last_base_url = Some("https://api.deepseek.com".to_string());
989 app.session.last_cache_inspection = Some(PromptInspection {
990 base_static_prefix_hash: "base".to_string(),
991 full_request_prefix_hash: "full".to_string(),
992 tool_catalog_hash: String::new(),
993 layers: Vec::new(),
994 });
995 app.push_turn_cache_record(TurnCacheRecord {
996 provider: None,
997 provider_identity: None,
998 model: None,
999 auto_model: false,
1000 input_tokens: 100,
1001 output_tokens: 25,
1002 cache_hit_tokens: Some(70),
1003 cache_miss_tokens: Some(30),
1004 reasoning_replay_tokens: Some(12),
1005 cache_write_tokens: None,
1006 reasoning_tokens: None,
1007 cost_audit: None,
1008 recorded_at: Instant::now(),
1009 });
1010
1011 clear(&mut app);
1012
1013 assert_eq!(app.session.total_tokens, 0);
1014 assert_eq!(app.session.total_conversation_tokens, 0);
1015 assert_eq!(app.session.session_cost, 0.0);
1016 assert_eq!(app.session.session_cost_cny, 0.0);
1017 assert_eq!(app.session.subagent_cost, 0.0);
1018 assert_eq!(app.session.subagent_cost_cny, 0.0);
1019 assert!(app.session.subagent_cost_event_seqs.is_empty());
1020 assert_eq!(app.session.displayed_cost_high_water, 0.0);
1021 assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);
1022 assert_eq!(app.session.last_prompt_cache_hit_tokens, None);
1023 assert_eq!(app.session.last_prompt_cache_miss_tokens, None);
1024 assert_eq!(app.session.last_reasoning_replay_tokens, None);
1025 assert_eq!(app.session.total_cache_write_tokens, 0);
1026 assert!(app.session.turn_cache_history.is_empty());
1027 assert_eq!(app.session.last_cache_inspection, None);
1028 assert_eq!(app.session.last_warmup_key, None);
1029 assert_eq!(app.session.last_tool_catalog, None);
1030 assert_eq!(app.session.last_base_url, None);
1031 }
1032
1033 #[test]
1034 fn test_exit_returns_quit_action() {
1035 let result = exit();
1036 assert!(result.message.is_none());
1037 assert!(matches!(result.action, Some(AppAction::Quit)));
1038 }
1039
1040 #[test]
1041 fn workspace_without_arg_shows_current_workspace() {
1042 let mut app = create_test_app();
1043 let result = workspace_switch(&mut app, None);
1044 let msg = result.message.expect("workspace should be shown");
1045 assert!(msg.contains("Current workspace:"));
1046 assert!(msg.contains("/tmp/test-workspace"));
1047 assert!(result.action.is_none());
1048 }
1049
1050 #[test]
1051 fn workspace_existing_absolute_dir_returns_switch_action() {
1052 let mut app = create_test_app();
1053 let dir = tempdir().expect("temp dir");
1054 let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap()));
1055 assert!(matches!(
1056 result.action,
1057 Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap()
1058 ));
1059 }
1060
1061 #[test]
1062 fn workspace_relative_dir_resolves_from_current_workspace() {
1063 let root = tempdir().expect("temp dir");
1064 let child = root.path().join("child");
1065 std::fs::create_dir(&child).expect("child dir");
1066 let mut app = create_test_app();
1067 app.workspace = root.path().to_path_buf();
1068
1069 let result = workspace_switch(&mut app, Some("child"));
1070 assert!(matches!(
1071 result.action,
1072 Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap()
1073 ));
1074 }
1075
1076 #[test]
1077 fn workspace_rejects_missing_path() {
1078 let mut app = create_test_app();
1079 let result = workspace_switch(&mut app, Some("definitely-missing"));
1080 assert!(result.is_error);
1081 assert!(result.message.unwrap().contains("does not exist"));
1082 }
1083
1084 #[test]
1085 fn workspace_rejects_file_path() {
1086 let root = tempdir().expect("temp dir");
1087 let file = root.path().join("file.txt");
1088 std::fs::write(&file, "not a directory").expect("test file");
1089 let mut app = create_test_app();
1090
1091 let result = workspace_switch(&mut app, Some(file.to_str().unwrap()));
1092 assert!(result.is_error);
1093 assert!(result.message.unwrap().contains("not a directory"));
1094 }
1095
1096 #[test]
1097 fn test_model_change_updates_state() {
1098 let _settings = SettingsPathGuard::new();
1099 let mut app = create_test_app();
1100 let old_model = app.model.clone();
1101 let result = model(&mut app, Some("deepseek-v4-flash"));
1102 assert!(result.message.is_some());
1103 let msg = result.message.unwrap();
1104 assert!(msg.contains(&old_model));
1105 assert!(msg.contains("deepseek-v4-flash"));
1106 assert!(matches!(
1107 result.action,
1108 Some(AppAction::UpdateCompaction(_))
1109 ));
1110 assert_eq!(app.model, "deepseek-v4-flash");
1111 assert_eq!(app.session.last_prompt_tokens, None);
1112 assert_eq!(app.session.last_completion_tokens, None);
1113 }
1114
1115 #[test]
1116 fn model_command_preserves_active_kimi_code_endpoint_for_bare_k3() {
1117 let _settings = SettingsPathGuard::new();
1118 let mut app = create_test_app();
1119 app.set_provider_identity(crate::config::ApiProvider::Moonshot, "moonshot");
1120 app.model_ids_passthrough = true;
1121 app.active_route_base_url = crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string();
1122 app.active_context_window_override = None;
1123
1124 let result = model(&mut app, Some(crate::config::KIMI_CODE_K3_MODEL));
1125
1126 assert!(
1127 !result.is_error,
1128 "Kimi Code K3 route should resolve: {result:?}"
1129 );
1130 assert_eq!(app.model, crate::config::KIMI_CODE_K3_MODEL);
1131 assert_eq!(
1132 app.active_route_limits
1133 .and_then(|limits| limits.context_tokens),
1134 Some(u64::from(crate::config::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS))
1135 );
1136
1137 // Switching to the direct platform endpoint requires the direct model
1138 // id (`kimi-k3`); bare `k3` is fail-closed (#4687).
1139 app.active_route_base_url = crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string();
1140 let rejected = model(&mut app, Some(crate::config::KIMI_CODE_K3_MODEL));
1141 assert!(
1142 rejected.is_error,
1143 "bare k3 on direct Moonshot must fail closed: {rejected:?}"
1144 );
1145
1146 let direct = model(&mut app, Some(crate::config::MOONSHOT_KIMI_K3_MODEL));
1147 assert!(
1148 !direct.is_error,
1149 "direct Moonshot kimi-k3 remains valid: {direct:?}"
1150 );
1151 assert_ne!(
1152 app.active_route_limits
1153 .and_then(|limits| limits.context_tokens),
1154 Some(u64::from(crate::config::KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS))
1155 );
1156 }
1157
1158 #[test]
1159 fn model_command_is_session_local_until_explicitly_saved() {
1160 let _settings = SettingsPathGuard::new();
1161 let mut app = create_test_app();
1162
1163 let result = model(&mut app, Some("deepseek-v4-flash"));
1164
1165 assert!(result.message.is_some());
1166 assert_eq!(
1167 app.provider_models.get("deepseek").map(String::as_str),
1168 Some("deepseek-v4-flash")
1169 );
1170 // The live session changed and the save decision is pending — the
1171 // route-save prompt owns persistence now.
1172 let pending = app.pending_route_save.as_ref().expect("pending save");
1173 assert_eq!(pending.provider_identity, "deepseek");
1174 assert_eq!(pending.model, "deepseek-v4-flash");
1175
1176 // NOTHING was written to settings: no scoped model, no default
1177 // provider, no default model. The message says the change is
1178 // session-only.
1179 let settings = crate::settings::Settings::load().expect("load settings");
1180 assert_eq!(
1181 settings
1182 .provider_models
1183 .as_ref()
1184 .and_then(|models| models.get("deepseek")),
1185 None
1186 );
1187 assert_eq!(settings.default_provider.as_deref(), None);
1188 assert_eq!(settings.default_model.as_deref(), None);
1189 assert!(
1190 result
1191 .message
1192 .as_deref()
1193 .unwrap_or_default()
1194 .contains("session only"),
1195 "the receipt must say the change is temporary: {:?}",
1196 result.message
1197 );
1198 }
1199
1200 #[test]
1201 fn model_command_does_not_mutate_shared_default_provider() {
1202 // Regression for #3227, strengthened: a `/model` change must not drag
1203 // the global `default_provider` onto it AND must not write the scoped
1204 // model either — the change is session-local until the user explicitly
1205 // saves it via the route-save prompt.
1206 let _settings = SettingsPathGuard::new();
1207 {
1208 let seed = crate::settings::Settings {
1209 default_provider: Some("deepseek".to_string()),
1210 ..Default::default()
1211 };
1212 seed.save().expect("seed settings");
1213 }
1214 let mut app = create_test_app();
1215 app.api_provider = crate::config::ApiProvider::Zai;
1216 app.model_ids_passthrough = false;
1217 app.model = crate::config::DEFAULT_ZAI_MODEL.to_string();
1218 app.auto_model = false;
1219
1220 let result = model(&mut app, Some("GLM-5.2"));
1221 assert!(result.message.is_some(), "expected a model-changed message");
1222 assert!(!result.is_error, "GLM-5.2 is valid on Z.ai");
1223
1224 let settings = crate::settings::Settings::load().expect("load settings");
1225 // The shared default provider is untouched.
1226 assert_eq!(settings.default_provider.as_deref(), Some("deepseek"));
1227 // No scoped entry was written either — session-local.
1228 assert_eq!(
1229 settings
1230 .provider_models
1231 .as_ref()
1232 .and_then(|models| models.get("zai")),
1233 None
1234 );
1235 // The in-memory route changed and the decision is pending.
1236 assert_eq!(app.model, "GLM-5.2");
1237 let pending = app.pending_route_save.as_ref().expect("pending save");
1238 assert_eq!(pending.provider_identity, "zai");
1239 }
1240
1241 #[test]
1242 fn two_sessions_keep_independent_provider_model_routes() {
1243 // #3227: two App instances sharing one settings/config path. A is on
1244 // Z.ai/GLM; B switches to DeepSeek and picks a DeepSeek model. B must
1245 // build a DeepSeek route (not Z.ai + a DeepSeek model), A must stay on
1246 // Z.ai/GLM, and neither session's `/model` may flip the shared global
1247 // default provider out from under the other.
1248 let _settings = SettingsPathGuard::new();
1249
1250 // Terminal A: Z.ai / GLM.
1251 let mut app_a = create_test_app();
1252 app_a.api_provider = crate::config::ApiProvider::Zai;
1253 app_a.model_ids_passthrough = false;
1254 app_a.model = crate::config::DEFAULT_ZAI_MODEL.to_string();
1255 app_a.auto_model = false;
1256 let result_a = model(&mut app_a, Some("GLM-5.2"));
1257 assert!(!result_a.is_error, "GLM-5.2 is valid on Z.ai");
1258 assert_eq!(app_a.api_provider, crate::config::ApiProvider::Zai);
1259 assert_eq!(app_a.model, "GLM-5.2");
1260
1261 // Terminal B: DeepSeek / deepseek-v4-flash.
1262 let mut app_b = create_test_app();
1263 app_b.api_provider = crate::config::ApiProvider::Deepseek;
1264 app_b.model_ids_passthrough = false;
1265 app_b.model = "deepseek-v4-pro".to_string();
1266 app_b.auto_model = false;
1267 let result_b = model(&mut app_b, Some("deepseek-v4-flash"));
1268 assert!(!result_b.is_error, "deepseek-v4-flash is valid on DeepSeek");
1269
1270 // B's route is a coherent DeepSeek route — never Z.ai + a DeepSeek model.
1271 assert_eq!(app_b.api_provider, crate::config::ApiProvider::Deepseek);
1272 assert_eq!(app_b.model, "deepseek-v4-flash");
1273
1274 // A is untouched by B's selection — still Z.ai / GLM.
1275 assert_eq!(app_a.api_provider, crate::config::ApiProvider::Zai);
1276 assert_eq!(app_a.model, "GLM-5.2");
1277
1278 // Shared settings: NOTHING was written by either `/model` — both
1279 // changes are session-local with a pending save decision, and the
1280 // global default provider was never flipped.
1281 let settings = crate::settings::Settings::load().expect("load settings");
1282 assert_eq!(settings.default_provider.as_deref(), None);
1283 assert_eq!(settings.provider_models, None);
1284 // Both sessions carry their own pending save decisions.
1285 assert_eq!(
1286 app_a.pending_route_save.as_ref().map(|p| p.model.as_str()),
1287 Some("GLM-5.2")
1288 );
1289 assert_eq!(
1290 app_b.pending_route_save.as_ref().map(|p| p.model.as_str()),
1291 Some("deepseek-v4-flash")
1292 );
1293 }
1294
1295 #[test]
1296 fn model_command_rejects_model_foreign_to_active_provider() {
1297 // #3227: a DeepSeek model id requested while the session is on Z.ai is
1298 // rejected locally with a precise diagnostic, before any network call.
1299 let _settings = SettingsPathGuard::new();
1300 let mut app = create_test_app();
1301 app.api_provider = crate::config::ApiProvider::Zai;
1302 app.model_ids_passthrough = false;
1303 app.model = crate::config::DEFAULT_ZAI_MODEL.to_string();
1304 app.auto_model = false;
1305 app.provider_models.clear();
1306
1307 let result = model(&mut app, Some("deepseek-v4-pro"));
1308
1309 assert!(result.is_error, "expected a local rejection");
1310 let msg = result.message.expect("error message");
1311 assert!(msg.contains("deepseek-v4-pro"), "names the model: {msg}");
1312 assert!(msg.contains("zai"), "names the provider: {msg}");
1313 // The session route is unchanged — still Z.ai / GLM.
1314 assert_eq!(app.api_provider, crate::config::ApiProvider::Zai);
1315 assert_eq!(app.model, crate::config::DEFAULT_ZAI_MODEL);
1316 }
1317
1318 #[test]
1319 fn model_switch_clears_turn_cache_history() {
1320 let _settings = SettingsPathGuard::new();
1321 let mut app = create_test_app();
1322 // Keep the assertion independent of the developer's saved default model.
1323 app.auto_model = false;
1324 app.model = "deepseek-v4-pro".to_string();
1325 app.push_turn_cache_record(TurnCacheRecord {
1326 provider: None,
1327 provider_identity: None,
1328 model: None,
1329 auto_model: false,
1330 input_tokens: 100,
1331 output_tokens: 25,
1332 cache_hit_tokens: Some(70),
1333 cache_miss_tokens: Some(30),
1334 reasoning_replay_tokens: Some(12),
1335 cache_write_tokens: None,
1336 reasoning_tokens: None,
1337 cost_audit: None,
1338 recorded_at: Instant::now(),
1339 });
1340
1341 let result = model(&mut app, Some("deepseek-v4-flash"));
1342
1343 assert!(result.message.is_some());
1344 assert!(app.session.turn_cache_history.is_empty());
1345 }
1346
1347 #[test]
1348 fn model_reset_same_model_keeps_turn_cache_history() {
1349 let _settings = SettingsPathGuard::new();
1350 let mut app = create_test_app();
1351 app.auto_model = false;
1352 app.model = "deepseek-v4-pro".to_string();
1353 app.push_turn_cache_record(TurnCacheRecord {
1354 provider: None,
1355 provider_identity: None,
1356 model: None,
1357 auto_model: false,
1358 input_tokens: 100,
1359 output_tokens: 25,
1360 cache_hit_tokens: Some(70),
1361 cache_miss_tokens: Some(30),
1362 reasoning_replay_tokens: Some(12),
1363 cache_write_tokens: None,
1364 reasoning_tokens: None,
1365 cost_audit: None,
1366 recorded_at: Instant::now(),
1367 });
1368
1369 let result = model(&mut app, Some("deepseek-v4-pro"));
1370
1371 assert!(result.message.is_some());
1372 assert_eq!(app.session.turn_cache_history.len(), 1);
1373 }
1374
1375 #[test]
1376 fn test_model_auto_enables_auto_thinking() {
1377 let _settings = SettingsPathGuard::new();
1378 let mut app = create_test_app();
1379 app.reasoning_effort = ReasoningEffort::Off;
1380 app.reasoning_effort_preference = None;
1381
1382 let result = model(&mut app, Some("auto"));
1383
1384 assert!(result.message.is_some());
1385 assert!(app.auto_model);
1386 assert_eq!(app.model, "auto");
1387 assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
1388 assert!(app.last_effective_model.is_none());
1389 assert!(app.last_effective_reasoning_effort.is_none());
1390 }
1391
1392 #[test]
1393 fn test_model_auto_preserves_raw_explicit_thinking() {
1394 let _settings = SettingsPathGuard::new();
1395 let mut app = create_test_app();
1396 app.api_provider = ApiProvider::OpenaiCodex;
1397 app.auto_model = false;
1398 app.reasoning_effort = ReasoningEffort::Low;
1399 app.reasoning_effort_preference = Some(ReasoningEffort::Off);
1400
1401 let result = model(&mut app, Some("auto"));
1402
1403 assert!(result.message.is_some());
1404 assert!(app.auto_model);
1405 assert_eq!(app.model, "auto");
1406 assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
1407 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
1408 }
1409
1410 #[test]
1411 fn test_model_change_accepts_future_deepseek_model() {
1412 let _settings = SettingsPathGuard::new();
1413 let mut app = create_test_app();
1414 let result = model(&mut app, Some("deepseek-v4"));
1415 assert!(result.message.is_some());
1416 let msg = result.message.unwrap();
1417 assert!(msg.contains("deepseek-v4"));
1418 assert_eq!(app.model, "deepseek-v4");
1419 assert!(matches!(
1420 result.action,
1421 Some(AppAction::UpdateCompaction(_))
1422 ));
1423 }
1424
1425 #[test]
1426 fn test_model_change_accepts_custom_id_for_openai_compatible_provider() {
1427 let _settings = SettingsPathGuard::new();
1428 let mut app = create_test_app();
1429 app.api_provider = crate::config::ApiProvider::Openai;
1430 app.model_ids_passthrough = true;
1431
1432 let result = model(&mut app, Some("opencode-go/glm-5.1"));
1433
1434 assert!(result.message.is_some());
1435 assert_eq!(app.model, "opencode-go/glm-5.1");
1436 assert!(!app.auto_model);
1437 assert!(matches!(
1438 result.action,
1439 Some(AppAction::UpdateCompaction(_))
1440 ));
1441 }
1442
1443 #[test]
1444 fn test_model_change_accepts_custom_id_for_custom_base_url() {
1445 let _settings = SettingsPathGuard::new();
1446 let mut app = create_test_app();
1447 app.model_ids_passthrough = true;
1448
1449 let result = model(&mut app, Some("opencode-go/kimi-k2.6"));
1450
1451 assert!(result.message.is_some());
1452 assert_eq!(app.model, "opencode-go/kimi-k2.6");
1453 assert!(matches!(
1454 result.action,
1455 Some(AppAction::UpdateCompaction(_))
1456 ));
1457 }
1458
1459 #[test]
1460 fn test_model_change_rejects_invalid_model() {
1461 let mut app = create_test_app();
1462 let result = model(&mut app, Some("gpt-4"));
1463 assert!(result.message.is_some());
1464 let msg = result.message.unwrap();
1465 assert!(msg.contains("Invalid model"));
1466 assert!(msg.contains("active provider"));
1467 assert!(msg.contains("deepseek"));
1468 assert!(!msg.contains("Common DeepSeek models"));
1469 assert!(result.action.is_none());
1470 }
1471
1472 #[test]
1473 fn model_command_rejects_saved_model_from_other_provider() {
1474 let mut app = create_test_app();
1475 app.api_provider = crate::config::ApiProvider::Deepseek;
1476 app.provider_models
1477 .insert("moonshot".to_string(), "kimi-k2.6".to_string());
1478
1479 let result = model(&mut app, Some("kimi-k2.6"));
1480
1481 let message = result.message.expect("invalid model message");
1482 assert!(message.contains("Invalid model"));
1483 assert!(message.contains("active provider"));
1484 assert!(result.action.is_none());
1485 assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek);
1486 assert_eq!(app.model, "deepseek-v4-pro");
1487 }
1488
1489 #[test]
1490 fn test_model_without_args_opens_picker() {
1491 let mut app = create_test_app();
1492 let result = model(&mut app, None);
1493 assert_eq!(result.message, None);
1494 assert_eq!(result.action, Some(AppAction::OpenModelPicker));
1495 }
1496
1497 #[test]
1498 fn test_models_triggers_fetch_action() {
1499 let mut app = create_test_app();
1500 let result = models(&mut app);
1501 assert!(result.message.is_none());
1502 assert!(matches!(result.action, Some(AppAction::FetchModels)));
1503 }
1504
1505 #[test]
1506 fn model_refresh_dispatches_models_dev_catalog_action() {
1507 let mut app = create_test_app();
1508 let result = model(&mut app, Some("refresh"));
1509 assert!(result.message.is_none());
1510 assert!(matches!(
1511 result.action,
1512 Some(AppAction::RefreshModelsDevCatalog)
1513 ));
1514 }
1515
1516 #[test]
1517 fn test_subagents_pushes_view_and_sets_status() {
1518 let mut app = create_test_app();
1519 let result = subagents(&mut app);
1520 assert!(result.message.is_none());
1521 assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
1522 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
1523 assert_eq!(
1524 app.status_message,
1525 Some("Fetching Fleet status...".to_string())
1526 );
1527 }
1528
1529 #[test]
1530 fn test_codewhale_links() {
1531 let mut app = create_test_app();
1532 let result = codewhale_links(&mut app);
1533 assert!(result.message.is_some());
1534 let msg = result.message.unwrap();
1535 assert!(msg.contains("Codewhale & community"));
1536 assert!(msg.contains("https://codewhale.net/en/docs"));
1537 assert!(msg.contains("https://codewhale.net/en/community"));
1538 assert!(msg.contains("https://github.com/Hmbown/CodeWhale"));
1539 assert!(msg.contains("https://app.codewhale.net"));
1540 assert!(msg.contains("separate sign-in"));
1541 assert!(msg.contains("not connected to the current local session"));
1542 assert!(msg.contains("Provider Links"));
1543 assert!(msg.contains("DeepSeek (deepseek) <- current"));
1544 assert!(msg.contains("https://platform.deepseek.com/api_keys"));
1545 assert!(msg.contains("Xiaomi MiMo (xiaomi-mimo)"));
1546 assert!(msg.contains("https://platform.xiaomimimo.com/token-plan"));
1547 assert!(msg.contains("Moonshot/Kimi (moonshot)"));
1548 assert!(msg.contains("https://platform.kimi.ai/console/api-keys"));
1549 assert!(msg.contains("https://platform.kimi.ai/docs/overview"));
1550 assert!(msg.contains("https://api.kimi.com/coding/v1"));
1551 assert!(msg.contains("https://www.kimi.com/code/console"));
1552 assert!(msg.contains("never imports Kimi CLI credentials"));
1553 assert!(msg.contains("https://console.openmodel.ai/"));
1554 assert!(msg.contains("https://docs.openmodel.ai/en/docs/getting-started/authentication"));
1555 assert!(msg.contains("https://console.sakana.ai/api-keys"));
1556 assert!(msg.contains("https://console.sakana.ai/get-started"));
1557 assert!(msg.contains("Baidu Qianfan (qianfan)"));
1558 assert!(msg.contains("https://cloud.baidu.com/doc/qianfan/index.html"));
1559 assert!(msg.contains("Local Ollama is keyless by default"));
1560 assert!(msg.contains("Run `codex login`"));
1561 assert!(msg.contains("no canonical vendor credential page exists"));
1562 assert!(msg.contains("OPENAI_API_KEY"));
1563 assert!(msg.contains("XIAOMI_MIMO_TOKEN_PLAN_API_KEY"));
1564 assert!(!msg.contains("https://codewhale.dev/docs/providers"));
1565 assert!(result.action.is_none());
1566 }
1567
1568 #[test]
1569 fn provider_links_emit_urls_as_inline_code_for_narrow_transcripts() {
1570 let mut app = create_test_app();
1571 let result = codewhale_links(&mut app);
1572 let msg = result.message.expect("links should return a message");
1573
1574 assert!(msg.contains("`https://platform.openai.com/api-keys`"));
1575 assert!(
1576 msg.contains(
1577 "`https://platform.minimax.io/user-center/basic-information/interface-key`"
1578 )
1579 );
1580
1581 for line in msg.lines().filter(|line| line.contains("http")) {
1582 let Some(url_start) = line.find("http") else {
1583 continue;
1584 };
1585 assert!(
1586 line[..url_start].ends_with('`') && line[url_start..].contains('`'),
1587 "provider URL should be inline-code wrapped so narrow TUI renders do not emit oversized OSC8 link payloads: {line}"
1588 );
1589 }
1590 }
1591
1592 #[test]
1593 fn provider_link_metadata_marks_custom_routes_as_configuration_owned() {
1594 let links =
1595 codewhale_config::provider::provider_for_kind(codewhale_config::ProviderKind::Custom)
1596 .credential_help();
1597
1598 assert_eq!(
1599 links.acquisition,
1600 codewhale_config::provider::CredentialAcquisition::Configuration
1601 );
1602 assert_eq!(links.docs_url, None);
1603 assert_eq!(links.credential_url, None);
1604 }
1605
1606 #[test]
1607 fn project_links_follow_the_available_public_site_locale() {
1608 let mut app = create_test_app();
1609 app.ui_locale = Locale::ZhHans;
1610
1611 let msg = codewhale_links(&mut app)
1612 .message
1613 .expect("links should return a message");
1614
1615 assert!(msg.contains("`https://codewhale.net/zh/docs`"));
1616 assert!(msg.contains("`https://codewhale.net/zh/community`"));
1617 assert!(msg.contains("`https://app.codewhale.net`"));
1618 }
1619
1620 #[test]
1621 fn test_home_dashboard_includes_all_sections() {
1622 let mut app = create_test_app();
1623 app.session.total_conversation_tokens = 1234;
1624 let result = home_dashboard(&mut app);
1625 assert!(result.message.is_some());
1626 let msg = result.message.unwrap();
1627 assert!(msg.contains("Codewhale"));
1628 assert!(!msg.contains("codewhale Home Dashboard"));
1629 assert!(msg.contains("Model:"));
1630 assert!(msg.contains("Mode:"));
1631 assert!(msg.contains("Workspace:"));
1632 assert!(msg.contains("History:"));
1633 assert!(msg.contains("Tokens:"));
1634 assert!(msg.contains("Quick Actions"));
1635 assert!(msg.contains("Mode Tips"));
1636 assert!(result.action.is_none());
1637 }
1638
1639 #[test]
1640 fn test_home_dashboard_shows_queued_when_present() {
1641 let mut app = create_test_app();
1642 app.queued_messages
1643 .push_back(crate::tui::app::QueuedMessage::new(
1644 "test".to_string(),
1645 None,
1646 ));
1647 let result = home_dashboard(&mut app);
1648 let msg = result.message.unwrap();
1649 assert!(msg.contains("Queued:"));
1650 }
1651
1652 #[test]
1653 fn test_home_dashboard_mode_tips_for_each_mode() {
1654 let modes = [
1655 AppMode::Agent,
1656 AppMode::Auto,
1657 AppMode::Yolo,
1658 AppMode::Plan,
1659 AppMode::Operate,
1660 ];
1661 for mode in modes {
1662 let mut app = create_test_app();
1663 app.mode = mode;
1664 let result = home_dashboard(&mut app);
1665 let msg = result.message.unwrap();
1666 assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
1667 }
1668 }
1669
1670 #[test]
1671 fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() {
1672 let mut app = create_test_app();
1673 let result = home_dashboard(&mut app);
1674 let msg = result
1675 .message
1676 .expect("home dashboard should return message");
1677 assert!(msg.contains("/links - Codewhale, community & provider links"));
1678 assert!(msg.contains("/config - Inspect and change settings"));
1679 assert!(
1680 !msg.lines()
1681 .any(|line| line.trim_start().starts_with("/set "))
1682 );
1683 assert!(!msg.contains("/codewhale"));
1684 }
1685
1686 #[test]
1687 fn home_dashboard_localizes_in_zh_hans() {
1688 use crate::localization::Locale;
1689 let mut app = create_test_app();
1690 app.ui_locale = Locale::ZhHans;
1691 let result = home_dashboard(&mut app);
1692 let msg = result
1693 .message
1694 .expect("home dashboard should return message");
1695 assert!(
1696 msg.contains("Codewhale"),
1697 "missing canonical product title:\n{msg}"
1698 );
1699 assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}");
1700 assert!(
1701 msg.contains("快捷操作"),
1702 "missing zh-Hans quick actions:\n{msg}"
1703 );
1704 assert!(
1705 msg.contains("模式提示"),
1706 "missing zh-Hans mode tips:\n{msg}"
1707 );
1708 }
1709 }
1710
1710 lines RUST