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