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