返回 CodeWhale
status.rs
根目录 / crates / tui / src / commands / groups / config / status.rs
1 //! Runtime status command.
2
3 use std::borrow::Cow;
4 use std::fmt::Write as _;
5 use std::path::Path;
6
7 use super::CommandResult;
8 use crate::compaction::estimate_input_tokens_conservative;
9 use crate::tui::app::{App, AppModeUi};
10 use crate::utils::{display_path, estimate_message_chars};
11 use codewhale_execpolicy::ApprovalMode;
12 use codewhale_localization::{Locale, MessageId, tr};
13
14 /// Show a compact runtime status report for the current TUI session.
15 pub fn status(app: &mut App) -> CommandResult {
16 CommandResult::message(format_status(app))
17 }
18
19 /// Models.dev live-layer freshness: source, row count, and age (#4187).
20 fn catalog_summary() -> String {
21 use crate::models_dev_live::ModelsDevFreshness;
22 let st = crate::models_dev_live::status();
23 let now = codewhale_config::catalog::now_unix();
24 let mut out = match st.freshness {
25 ModelsDevFreshness::Bundled => "bundled".to_string(),
26 ModelsDevFreshness::Live => "models.dev live".to_string(),
27 ModelsDevFreshness::Stale => "models.dev stale".to_string(),
28 ModelsDevFreshness::Failed => "models.dev refresh failed".to_string(),
29 };
30 if st.offering_count > 0 {
31 let _ = write!(out, " · {} offerings", st.offering_count);
32 }
33 if let Some(fetched_at) = st.fetched_at {
34 let _ = write!(
35 out,
36 " · fetched {}",
37 codewhale_config::cloud_facts::provenance::age_label(fetched_at, now)
38 );
39 }
40 if let Some(err) = st.last_error.as_deref().filter(|e| !e.is_empty())
41 && st.freshness == ModelsDevFreshness::Failed
42 {
43 let _ = write!(out, " ({err})");
44 }
45 out
46 }
47
48 /// Cloud facts provenance: channel, version, key, age, origin — or why the
49 /// bundled facts are in use. Off by default.
50 fn cloud_facts_summary() -> String {
51 let status = codewhale_cloud_facts::status();
52 if status.state == codewhale_config::cloud_facts::CloudFactsState::Off {
53 // The adjacent catalog source already describes the available facts.
54 // Repeating "bundled" here also mislabels a live Models.dev catalog.
55 "off".to_string()
56 } else {
57 status.label(codewhale_config::catalog::now_unix())
58 }
59 }
60
61 /// Row label column, in columns. English's widest label is `Context window:`
62 /// (15); the tail space in [`push_row`] makes its value start at column 19.
63 /// Longer localized labels extend naturally rather than being truncated.
64 const LABEL_WIDTH: usize = 16;
65
66 fn format_status(app: &App) -> String {
67 let mut out = String::new();
68 let locale = app.ui_locale;
69 let (context_used, context_max, context_percent) = context_usage(app);
70
71 // A transcript cell has no ink and no rules, so the only grouping mark
72 // available is a blank row. It is spent on the two group boundaries and
73 // nowhere else: standing facts about the route and the machine first,
74 // then everything that accumulates as the session runs.
75 let _ = writeln!(out, "codewhale {}", env!("CARGO_PKG_VERSION"));
76 let _ = writeln!(out);
77
78 push_row(
79 &mut out,
80 locale,
81 MessageId::StatusLabelRoute,
82 &route_summary(app),
83 );
84 push_row(
85 &mut out,
86 locale,
87 MessageId::StatusLabelDirectory,
88 &display_path(&app.workspace),
89 );
90 push_row(
91 &mut out,
92 locale,
93 MessageId::StatusLabelProjectDocs,
94 &project_docs(&app.workspace, locale),
95 );
96 push_row(
97 &mut out,
98 locale,
99 MessageId::StatusLabelMode,
100 &posture_summary(app),
101 );
102 push_row(
103 &mut out,
104 locale,
105 MessageId::StatusLabelSafety,
106 safety_summary(app).as_ref(),
107 );
108 push_row(
109 &mut out,
110 locale,
111 MessageId::StatusLabelMcp,
112 &localized(
113 locale,
114 MessageId::StatusMcpConfigured,
115 &[("{count}", &app.mcp_configured_count.to_string())],
116 ),
117 );
118 let config =
119 crate::config::Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok();
120 if let Some(notice) = config
121 .as_ref()
122 .and_then(|config| session_model_drift_notice(app, config, locale))
123 {
124 let _ = writeln!(out, " {notice}");
125 }
126 if let Some(drift) = config
127 .as_ref()
128 .and_then(|config| fleet_drift_summary(app, config, locale))
129 {
130 push_row(&mut out, locale, MessageId::StatusLabelFleet, &drift);
131 }
132 if let Some(notice) = crate::core::turn::snapshots_disabled_status(
133 &app.workspace,
134 app.current_session_id.as_deref(),
135 ) {
136 let _ = writeln!(out, " {}", notice.localize(locale));
137 }
138 let _ = writeln!(out);
139
140 push_row(
141 &mut out,
142 locale,
143 MessageId::StatusLabelContextWindow,
144 &localized(
145 locale,
146 MessageId::StatusContextUsage,
147 &[
148 ("{percent}", &format!("{context_percent:.1}")),
149 ("{used}", &context_used.to_string()),
150 ("{max}", &context_max.to_string()),
151 ],
152 ),
153 );
154 let mut source_summary =
155 context_window_source_label(context_window_source(app), locale).into_owned();
156 // The default bundled source needs no second catalog label. Keeping it
157 // compact preserves the 80-column budget as well as the report's row count.
158 if crate::models_dev_live::status().freshness
159 != crate::models_dev_live::ModelsDevFreshness::Bundled
160 {
161 let _ = write!(
162 source_summary,
163 " · {}: {}",
164 tr(locale, MessageId::StatusLabelCatalog),
165 catalog_summary()
166 );
167 }
168 let _ = write!(
169 source_summary,
170 " · {}: {}",
171 tr(locale, MessageId::StatusLabelCloudFacts),
172 cloud_facts_summary()
173 );
174 push_row(
175 &mut out,
176 locale,
177 MessageId::StatusLabelWindowSource,
178 &source_summary,
179 );
180 if let Some(key) = context_window_override_key(app, locale) {
181 push_row(&mut out, locale, MessageId::StatusLabelWindowOverride, &key);
182 }
183 push_row(
184 &mut out,
185 locale,
186 MessageId::StatusLabelSession,
187 &session_summary(app),
188 );
189 push_row(
190 &mut out,
191 locale,
192 MessageId::StatusLabelSessionTokens,
193 &session_tokens(app),
194 );
195 push_row(
196 &mut out,
197 locale,
198 MessageId::StatusLabelSessionCost,
199 &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)),
200 );
201 // The full, untrimmed session metrics strip (the footer sheds groups to
202 // fit; here every group that has evidence is printed). It keeps its own
203 // template because the label and metrics form one localized sentence.
204 let snapshot = crate::tui::session_metrics::snapshot_from_app(app);
205 if !snapshot.is_empty() {
206 let metrics = crate::tui::session_metrics::full_text(
207 snapshot,
208 app.ui_locale,
209 crate::tui::color_compat::ascii_safe_enabled(),
210 );
211 let _ = writeln!(
212 out,
213 " {}",
214 tr(locale, MessageId::SessionMetricsStatusLine).replace("{metrics}", &metrics)
215 );
216 }
217 let tool_output_status =
218 crate::tool_output_receipts::tool_output_status(&app.api_messages, &app.session_artifacts);
219 push_row(
220 &mut out,
221 locale,
222 MessageId::StatusLabelToolOutputs,
223 &crate::tool_output_receipts::format_tool_output_status(&tool_output_status, locale),
224 );
225 let _ = writeln!(out);
226 // Two whole fields left this report rather than being printed at the same
227 // weight as everything else: the per-turn token ledger, which `/tokens`
228 // already prints in full, and the list of enabled footer item keys, which
229 // is `/statusline`'s own subject. The pointer costs one row; they cost
230 // seven.
231 let _ = writeln!(out, " {}", tr(locale, MessageId::StatusPointers));
232
233 out
234 }
235
236 /// Provider, model, and effort as one lockup, matching the header rail.
237 ///
238 /// These were three rows (`Provider:`, `Model:` with the effort parenthesised)
239 /// for one fact — which route is this turn going to. The header already joins
240 /// them with a middle dot; `/status` now agrees with it.
241 fn route_summary(app: &App) -> String {
242 let model = app.model_display_label();
243 let reasoning = app.reasoning_effort_display_label();
244 localized(
245 app.ui_locale,
246 MessageId::StatusRouteSummary,
247 &[
248 ("{provider}", app.provider_identity_for_persistence()),
249 ("{model}", &model),
250 ("{reasoning}", &reasoning),
251 ],
252 )
253 }
254
255 /// Mode and the permissions that qualify it, as one statement of posture.
256 fn posture_summary(app: &App) -> String {
257 let trust = if app.trust_mode {
258 tr(app.ui_locale, MessageId::StatusTrustedWorkspace)
259 } else {
260 tr(app.ui_locale, MessageId::StatusWorkspace)
261 };
262 let shell = if app.allow_shell {
263 tr(app.ui_locale, MessageId::StatusShellOn)
264 } else {
265 tr(app.ui_locale, MessageId::StatusShellOff)
266 };
267 let mode = app.mode.display_name_localized(app.ui_locale);
268 let approval = approval_summary(app.approval_mode, app.ui_locale);
269 localized(
270 app.ui_locale,
271 MessageId::StatusPostureSummary,
272 &[
273 ("{mode}", mode.as_ref()),
274 ("{approval}", approval.as_ref()),
275 ("{shell}", shell.as_ref()),
276 ("{trust}", trust.as_ref()),
277 ],
278 )
279 }
280
281 fn approval_summary(mode: ApprovalMode, locale: Locale) -> Cow<'static, str> {
282 tr(
283 locale,
284 match mode {
285 ApprovalMode::Suggest => MessageId::StatusApprovalAsk,
286 ApprovalMode::Auto => MessageId::StatusApprovalAuto,
287 ApprovalMode::Bypass => MessageId::StatusApprovalFullAccess,
288 ApprovalMode::Never => MessageId::StatusApprovalNever,
289 },
290 )
291 }
292
293 /// Session identity and the size of the conversation it names.
294 fn session_summary(app: &App) -> String {
295 let session = app
296 .current_session_id
297 .clone()
298 .unwrap_or_else(|| tr(app.ui_locale, MessageId::StatusSessionNotSaved).into_owned());
299 localized(
300 app.ui_locale,
301 MessageId::StatusSessionSummary,
302 &[
303 ("{session}", &session),
304 ("{cells}", &app.history.len().to_string()),
305 ("{messages}", &app.api_messages.len().to_string()),
306 ],
307 )
308 }
309
310 /// Cumulative token ledger on one row.
311 ///
312 /// The session input/output split and the cumulative cache totals live only
313 /// here; the per-turn figures they used to sit beside are `/tokens`.
314 fn session_tokens(app: &App) -> String {
315 let cache = if app.session.displayed_total_cache_hit_tokens() == 0
316 && app.session.displayed_total_cache_miss_tokens() == 0
317 {
318 tr(app.ui_locale, MessageId::StatusCacheNotReported).into_owned()
319 } else {
320 localized(
321 app.ui_locale,
322 MessageId::StatusCacheSummary,
323 &[
324 (
325 "{hit}",
326 &app.session.displayed_total_cache_hit_tokens().to_string(),
327 ),
328 (
329 "{miss}",
330 &app.session.displayed_total_cache_miss_tokens().to_string(),
331 ),
332 ],
333 )
334 };
335 localized(
336 app.ui_locale,
337 MessageId::StatusSessionTokensSummary,
338 &[
339 (
340 "{input}",
341 &app.session.displayed_total_input_tokens().to_string(),
342 ),
343 (
344 "{output}",
345 &app.session.displayed_total_output_tokens().to_string(),
346 ),
347 ("{total}", &app.session.displayed_total_tokens().to_string()),
348 ("{cache}", &cache),
349 ],
350 )
351 }
352
353 fn push_row(out: &mut String, locale: Locale, label: MessageId, value: &str) {
354 let label = format!("{}:", tr(locale, label));
355 let _ = writeln!(out, " {label:<LABEL_WIDTH$} {value}");
356 }
357
358 /// Selected-Fleet pin drift: saved `(provider, model)` pairs that are no
359 /// longer among the routes the Fleet picker can offer — the provider table
360 /// was removed, or the model dropped out of the provider's roster. A pin may
361 /// still serve upstream, so this reports and never rewrites. `None` when no
362 /// Fleet is selected or nothing drifted.
363 fn fleet_drift_summary(
364 app: &App,
365 config: &crate::config::Config,
366 locale: Locale,
367 ) -> Option<String> {
368 let selected = crate::fleet::store::selected_fleet(&app.workspace)?;
369 let (fleet, _scope) = crate::fleet::store::load_fleet_at(&selected.path).ok()?;
370 let active = config.active_provider_identity().ok();
371 let health = crate::provider_readiness::ProviderReadinessSnapshot::default();
372 let routes = crate::tui::views::fleet_setup::cross_provider_model_routes(
373 config,
374 active.as_ref(),
375 &health,
376 );
377 let offered =
378 |provider: &str, model: &str| routes.iter().any(|(p, m, _)| p == provider && m == model);
379 let mut drifted: Vec<String> = Vec::new();
380 if let Some(operator) = &fleet.operator
381 && !offered(&operator.provider, &operator.model)
382 {
383 drifted.push("operator".to_string());
384 }
385 for member in &fleet.members {
386 if let (Some(provider), Some(model)) = (&member.provider, &member.model)
387 && !offered(provider, model)
388 {
389 drifted.push(member.id.clone());
390 }
391 }
392 if drifted.is_empty() {
393 return None;
394 }
395 Some(localized(
396 locale,
397 MessageId::StatusFleetDrifted,
398 &[
399 ("{fleet}", &fleet.name),
400 ("{count}", &drifted.len().to_string()),
401 ("{ids}", &drifted.join(", ")),
402 ],
403 ))
404 }
405
406 /// The session's own pinned model, read-only (#6035): when the active route
407 /// has a fresh live roster that no longer lists the pinned id, say so. The pin
408 /// is never rewritten — the id may still answer, and a stale or missing
409 /// roster proves nothing, so it stays silent then. `None` under Auto routing.
410 fn session_model_drift_notice(
411 app: &App,
412 config: &crate::config::Config,
413 locale: Locale,
414 ) -> Option<String> {
415 if app.auto_model || app.model.trim().is_empty() {
416 return None;
417 }
418 let provider = app.provider_identity_for_persistence();
419 crate::provider_catalog_live::pin_missing_from_fresh_roster(config, provider, &app.model)
420 .filter(|missing| *missing)?;
421 Some(localized(
422 locale,
423 MessageId::StatusModelNotInRoster,
424 &[("{model}", &app.model), ("{provider}", provider)],
425 ))
426 }
427
428 fn safety_summary(app: &App) -> Cow<'static, str> {
429 let policy = crate::core::authority::sandbox_policy_for_turn(
430 app.mode,
431 app.approval_mode,
432 app.configured_sandbox_mode.as_deref(),
433 &app.workspace,
434 crate::core::authority::SandboxNetworkAccess::from_config(app.configured_sandbox_network),
435 );
436 // The policy is the intent; `sandbox_backend` is what this platform can
437 // actually enforce with. Default Linux (bubblewrap is opt-in) and all
438 // Windows have none, and /status used to report "sandbox workspace-write"
439 // while nothing was restricted (2026-08-04 audit). `doctor` has always
440 // been honest about this; /status now agrees with it.
441 let unenforced = app.sandbox_backend.is_none();
442 let message = match policy {
443 crate::sandbox::SandboxPolicy::ReadOnly if unenforced => {
444 MessageId::StatusSafetyReadOnlyUnenforced
445 }
446 crate::sandbox::SandboxPolicy::ReadOnly => MessageId::StatusSafetyReadOnly,
447 // Read the flag rather than assuming it. Workspace-write defaults to
448 // network-restricted, so a hardcoded "network on" here named a
449 // boundary the policy does not grant.
450 crate::sandbox::SandboxPolicy::WorkspaceWrite { network_access, .. } if unenforced => {
451 if network_access {
452 MessageId::StatusSafetyWorkspaceWriteUnenforcedNetworkOn
453 } else {
454 MessageId::StatusSafetyWorkspaceWriteUnenforcedNetworkOff
455 }
456 }
457 crate::sandbox::SandboxPolicy::WorkspaceWrite { network_access, .. } => {
458 if network_access {
459 MessageId::StatusSafetyWorkspaceWriteNetworkOn
460 } else {
461 MessageId::StatusSafetyWorkspaceWriteNetworkOff
462 }
463 }
464 crate::sandbox::SandboxPolicy::DangerFullAccess => {
465 safety_disabled_message(crate::sandbox::process_hardening::no_new_privs_active())
466 }
467 crate::sandbox::SandboxPolicy::ExternalSandbox { .. } => MessageId::StatusSafetyExternal,
468 };
469 tr(app.ui_locale, message)
470 }
471
472 /// The full-access safety row must disclose the residual setuid block
473 /// truthfully (#5723): the no-new-privileges kernel flag is set at startup in
474 /// every narrower posture and is irreversible, so "sandbox disabled" alone
475 /// would promise `sudo`/setuid workflows the process tree cannot perform.
476 /// `None` is a platform without the flag, where the plain label is accurate.
477 fn safety_disabled_message(no_new_privs_active: Option<bool>) -> MessageId {
478 match no_new_privs_active {
479 Some(true) => MessageId::StatusSafetyDisabledSetuidBlocked,
480 Some(false) => MessageId::StatusSafetyDisabledSetuidAllowed,
481 None => MessageId::StatusSafetyDisabled,
482 }
483 }
484
485 fn project_docs(workspace: &Path, locale: Locale) -> String {
486 let docs: Vec<&str> = ["AGENTS.md", "CLAUDE.md"]
487 .into_iter()
488 .filter(|name| workspace.join(name).is_file())
489 .collect();
490 if docs.is_empty() {
491 tr(locale, MessageId::StatusProjectDocsNone).into_owned()
492 } else {
493 docs.join(", ")
494 }
495 }
496
497 fn context_usage(app: &App) -> (usize, u32, f64) {
498 let max = crate::route_budget::route_context_window_tokens(
499 app.api_provider,
500 app.effective_model_for_budget(),
501 app.active_route_limits,
502 );
503 let estimated =
504 estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
505 let total_chars = estimate_message_chars(&app.api_messages);
506 let used = estimated.max(total_chars / 4);
507 let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0);
508 (used, max, percent)
509 }
510
511 /// Where the effective context window came from.
512 ///
513 /// #5134: `/status` printed the window as a bare number, so a user watching
514 /// auto-compaction fire at 128K on a 1M-capable model had no way to learn that
515 /// `context_window` exists, let alone which table it belongs on. The
516 /// provenance label alone is not enough — the actionable half is the key path,
517 /// which now gets its own aligned row rather than a parenthesis that wrapped
518 /// the provenance off the end of the line.
519 fn context_window_source(app: &App) -> crate::route_runtime::ContextWindowSource {
520 app.active_context_window_source
521 }
522
523 fn context_window_source_label(
524 source: crate::route_runtime::ContextWindowSource,
525 locale: Locale,
526 ) -> Cow<'static, str> {
527 tr(
528 locale,
529 match source {
530 crate::route_runtime::ContextWindowSource::Configured
531 | crate::route_runtime::ContextWindowSource::UserDeclared => {
532 MessageId::StatusContextSourceConfigured
533 }
534 crate::route_runtime::ContextWindowSource::ConfiguredModel => {
535 MessageId::StatusContextSourceConfiguredModel
536 }
537 crate::route_runtime::ContextWindowSource::ProviderReported => {
538 MessageId::StatusContextSourceProviderReported
539 }
540 crate::route_runtime::ContextWindowSource::StaticKimiCodeSafeFloor => {
541 MessageId::StatusContextSourceKimiSafeFloor
542 }
543 crate::route_runtime::ContextWindowSource::Catalog => {
544 MessageId::StatusContextSourceCatalog
545 }
546 crate::route_runtime::ContextWindowSource::NameSuffixHint => {
547 MessageId::StatusContextSourceModelHint
548 }
549 crate::route_runtime::ContextWindowSource::Fallback => {
550 MessageId::StatusContextSourceFallback
551 }
552 },
553 )
554 }
555
556 /// The exact key that changes the window, or `None` when the user already set
557 /// it and the row would be naming a key they have already used.
558 fn context_window_override_key(app: &App, locale: Locale) -> Option<String> {
559 if matches!(
560 app.active_context_window_source,
561 crate::route_runtime::ContextWindowSource::Configured
562 | crate::route_runtime::ContextWindowSource::ConfiguredModel
563 ) {
564 return None;
565 }
566 let table = app
567 .provider_identity
568 .as_ref()
569 .and_then(|identity| identity.config_table_key().ok());
570 Some(match table {
571 Some(table) => localized(
572 locale,
573 MessageId::StatusWindowOverrideProvider,
574 &[("{table}", table)],
575 ),
576 None => tr(locale, MessageId::StatusWindowOverrideActiveProvider).into_owned(),
577 })
578 }
579
580 fn localized(locale: Locale, id: MessageId, replacements: &[(&str, &str)]) -> String {
581 let template = tr(locale, id);
582 let mut message = String::with_capacity(template.len());
583 let mut cursor = 0;
584
585 while let Some(relative_start) = template[cursor..].find('{') {
586 let start = cursor + relative_start;
587 message.push_str(&template[cursor..start]);
588
589 let Some(relative_end) = template[start..].find('}') else {
590 message.push_str(&template[start..]);
591 return message;
592 };
593 let end = start + relative_end + 1;
594 let placeholder = &template[start..end];
595 if let Some(value) = replacements
596 .iter()
597 .find_map(|(candidate, value)| (*candidate == placeholder).then_some(*value))
598 {
599 message.push_str(value);
600 } else {
601 message.push_str(placeholder);
602 }
603 cursor = end;
604 }
605
606 message.push_str(&template[cursor..]);
607 message
608 }
609
610 #[cfg(test)]
611 mod tests {
612 use codewhale_models::Role;
613 use std::path::PathBuf;
614
615 use tempfile::TempDir;
616
617 use super::*;
618 use crate::config::{Config, ProviderKind};
619 use crate::tui::app::TuiOptions;
620 use crate::tui::history::HistoryCell;
621 use codewhale_config::AppMode;
622 use codewhale_models::{ContentBlock, Message};
623
624 #[test]
625 fn status_keeps_current_session_snapshot_remedy_after_notice_delivery() {
626 let _env = crate::test_support::lock_test_env();
627 let root = TempDir::new().unwrap();
628 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
629 let _user_home = crate::test_support::EnvVarGuard::set("HOME", root.path());
630 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", root.path());
631 let workspace = root.path().join("workspace");
632 std::fs::create_dir(&workspace).unwrap();
633 std::fs::write(workspace.join("large.txt"), vec![b'x'; 4096]).unwrap();
634 let mut app = create_test_app(workspace.clone());
635 app.current_session_id = Some("session-a".into());
636 assert!(
637 crate::core::turn::pre_turn_snapshot(&workspace, 1, 1024, None, Some("session-a"))
638 .is_none()
639 );
640 assert_eq!(
641 crate::core::turn::take_snapshots_disabled_notices(&workspace, Some("session-a")).len(),
642 1
643 );
644 for _ in 0..2 {
645 let report = status(&mut app).message.unwrap();
646 assert!(report.contains("Snapshots and /undo are off"), "{report}");
647 assert!(report.contains("snapshot-eligible content"), "{report}");
648 // Stated once, not doubled by a raw reason plus a template.
649 assert_eq!(
650 report
651 .matches(crate::core::turn::SNAPSHOTS_CAP_CONFIG_KEY)
652 .count(),
653 1,
654 "{report}"
655 );
656 }
657 app.current_session_id = Some("session-b".into());
658 assert!(
659 !status(&mut app)
660 .message
661 .unwrap()
662 .contains("Snapshots and /undo are off")
663 );
664 app.current_session_id = Some("session-a".into());
665 assert!(
666 crate::core::turn::pre_turn_snapshot(&workspace, 2, 0, None, Some("session-a"))
667 .is_some()
668 );
669 assert!(
670 !status(&mut app)
671 .message
672 .unwrap()
673 .contains("Snapshots and /undo are off")
674 );
675 }
676
677 #[test]
678 fn status_warns_when_the_session_pin_left_a_fresh_roster_and_keeps_it() {
679 // #6035: warning only. The pin is never rewritten, and a route with
680 // no fresh roster proves nothing, so it stays silent.
681 let _env = crate::test_support::lock_test_env();
682 let _live = crate::provider_lake::lock_live_snapshot();
683 let root = TempDir::new().unwrap();
684 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
685 let _user_home = crate::test_support::EnvVarGuard::set("HOME", root.path());
686 let _user_profile = crate::test_support::EnvVarGuard::set("USERPROFILE", root.path());
687 crate::provider_catalog_live::reset_cache_for_test();
688 let workspace = root.path().join("workspace");
689 std::fs::create_dir(&workspace).unwrap();
690 let mut app = create_test_app(workspace);
691 app.auto_model = false;
692 app.model = "deepseek-v4-flash".to_string();
693 let notice = "is not in deepseek's current model list";
694 assert!(
695 !status(&mut app).message.unwrap().contains(notice),
696 "no fresh roster, no claim"
697 );
698
699 let config = Config::load(app.config_path.clone(), app.config_profile.as_deref())
700 .unwrap_or_default();
701 let base_url = config.base_url_for_route(
702 &config
703 .resolve_provider_selection_identity("deepseek")
704 .unwrap(),
705 );
706 let fingerprint = codewhale_config::catalog::base_url_fingerprint(&base_url);
707 let fetched_at = codewhale_config::catalog::now_unix();
708 crate::provider_catalog_live::record_success(
709 codewhale_config::catalog::ProviderCatalogDelta {
710 provider: "deepseek".to_string(),
711 base_url_fingerprint: fingerprint.clone(),
712 fetched_at,
713 offerings: vec![codewhale_config::catalog::CatalogOffering {
714 provider: "deepseek".to_string(),
715 wire_model_id: "deepseek-flash".to_string(),
716 endpoint_key: "chat".to_string(),
717 source: codewhale_config::catalog::CatalogSource::Live {
718 base_url_fingerprint: fingerprint,
719 fetched_at,
720 },
721 ..Default::default()
722 }],
723 },
724 );
725
726 let report = status(&mut app).message.unwrap();
727 assert!(report.contains(notice), "{report}");
728 assert!(report.contains("deepseek-v4-flash"), "{report}");
729 assert_eq!(app.model, "deepseek-v4-flash", "the pin is left unchanged");
730
731 app.model = "deepseek-flash".to_string();
732 assert!(!status(&mut app).message.unwrap().contains(notice));
733 app.model = "deepseek-v4-flash".to_string();
734 app.auto_model = true;
735 assert!(!status(&mut app).message.unwrap().contains(notice));
736 crate::provider_catalog_live::reset_cache_for_test();
737 }
738
739 fn create_test_app(workspace: PathBuf) -> App {
740 let options = TuiOptions {
741 skills_dir: PathBuf::from("/tmp/test-skills"),
742 ..crate::test_support::test_tui_options(workspace)
743 };
744 let mut app = App::new(options, &Config::default());
745 app.api_provider = ProviderKind::Deepseek;
746 app
747 }
748
749 #[test]
750 fn status_report_includes_runtime_fields() {
751 let tmpdir = TempDir::new().expect("temp dir");
752 std::fs::write(tmpdir.path().join("AGENTS.md"), "# Instructions").expect("write docs");
753 let mut app = create_test_app(tmpdir.path().to_path_buf());
754 app.current_session_id = Some("session-123".to_string());
755 app.session.total_tokens = 1234;
756 app.session.last_prompt_tokens = Some(100);
757 app.session.last_completion_tokens = Some(25);
758 app.session.last_prompt_cache_hit_tokens = Some(70);
759 app.session.last_prompt_cache_miss_tokens = Some(30);
760 app.api_messages_mut().push(Message {
761 role: Role::User,
762 content: vec![ContentBlock::Text {
763 text: "hello".to_string(),
764 cache_control: None,
765 }],
766 });
767 app.history.push(HistoryCell::User {
768 content: "hello".to_string(),
769 });
770
771 let result = status(&mut app);
772 let msg = result.message.expect("status message");
773 assert!(msg.starts_with(&format!("codewhale {}", env!("CARGO_PKG_VERSION"))));
774 assert!(msg.contains("Route:"));
775 assert!(msg.contains("Directory:"));
776 assert!(msg.contains("AGENTS.md"));
777 assert!(msg.contains("Mode:"));
778 assert!(msg.contains("approvals"));
779 assert!(msg.contains("Session:"));
780 assert!(msg.contains("session-123"));
781 assert!(msg.contains("Context window:"));
782 assert!(msg.contains("Tool outputs:"));
783 assert!(msg.contains("Session tokens:"));
784 assert!(msg.contains("/tokens"));
785 assert!(msg.contains("/statusline"));
786 }
787
788 /// Every row has to earn its place in a 24-row terminal. The report used
789 /// to run 31 lines, so at 80x24 — where the transcript viewport is 18
790 /// rows — a user who typed `/status` landed on the *tail*: the version,
791 /// route, directory, mode and sandbox rows had already scrolled off, and
792 /// what remained on screen was five "not reported" rows and a `$0.0000`.
793 ///
794 /// A fresh session is 18 rows, not 17: `Window override:` is present
795 /// unless the value is already configured. That matches the viewport
796 /// height, so the title still scrolls off once `/status` occupies a
797 /// history cell.
798 #[test]
799 fn status_report_fits_a_short_terminal() {
800 let tmpdir = TempDir::new().expect("temp dir");
801 let mut app = create_test_app(tmpdir.path().to_path_buf());
802 let msg = status(&mut app).message.expect("status message");
803 let rows = msg.lines().count();
804 assert!(
805 msg.contains("Window override:"),
806 "fresh session keeps the override row: {msg}"
807 );
808 assert_eq!(
809 rows, 18,
810 "fresh session is 18 rows with Window override present, got {rows} rows:\n{msg}"
811 );
812 let source = msg
813 .lines()
814 .find(|line| line.contains("Window source:"))
815 .unwrap();
816 assert!(
817 source.chars().count() <= 80,
818 "fresh source provenance must not wrap: {source}"
819 );
820 }
821
822 /// `Rate limits:` was a `push_row` of a string literal — it could never
823 /// report anything but "not available from provider telemetry". A row
824 /// that cannot say anything cannot inform, and it cost a row on every
825 /// terminal forever.
826 #[test]
827 fn status_report_drops_the_row_that_could_never_say_anything() {
828 let tmpdir = TempDir::new().expect("temp dir");
829 let mut app = create_test_app(tmpdir.path().to_path_buf());
830 let msg = status(&mut app).message.expect("status message");
831 assert!(!msg.contains("Rate limits"), "{msg}");
832 assert!(
833 !msg.contains("not available from provider telemetry"),
834 "{msg}"
835 );
836 }
837
838 /// The per-turn ledger is `/tokens`' whole subject and `/status` printed
839 /// six rows of it. Shedding the field beats printing it at the same
840 /// weight as the sandbox policy — but only if the report says where it
841 /// went, and only if the two facts that live nowhere else (the
842 /// cumulative in/out split and the cumulative cache totals) survive.
843 #[test]
844 fn status_report_sheds_the_per_turn_ledger_and_names_where_it_went() {
845 let tmpdir = TempDir::new().expect("temp dir");
846 let mut app = create_test_app(tmpdir.path().to_path_buf());
847 app.session.total_input_tokens = 900;
848 app.session.total_output_tokens = 120;
849 app.session.total_tokens = 1020;
850 app.session.total_cache_hit_tokens = 700;
851 app.session.total_cache_miss_tokens = 200;
852 app.session.last_prompt_tokens = Some(100);
853
854 let msg = status(&mut app).message.expect("status message");
855
856 for shed in [
857 "Last API input:",
858 "Last API output:",
859 "Cache hit/miss:",
860 "Session input:",
861 "Session output:",
862 "Total tokens:",
863 "Session cache:",
864 ] {
865 assert!(
866 !msg.contains(shed),
867 "{shed} should be shed, not printed: {msg}"
868 );
869 }
870 assert!(msg.contains("Per-turn tokens: /tokens"), "{msg}");
871 // The footer-item *keys* were a full-width row of internal config
872 // names; `/statusline` is the surface that owns them.
873 assert!(!msg.contains("reasoning_replay"), "{msg}");
874 assert!(!msg.contains("git_branch"), "{msg}");
875 assert!(msg.contains("Footer items: /statusline"), "{msg}");
876
877 let row = msg
878 .lines()
879 .find(|line| line.trim_start().starts_with("Session tokens:"))
880 .expect("session tokens row");
881 assert!(row.contains("900 in"), "{row}");
882 assert!(row.contains("120 out"), "{row}");
883 assert!(row.contains("1020 total"), "{row}");
884 assert!(row.contains("cache 700 hit / 200 miss"), "{row}");
885 }
886
887 /// Provider, model and effort are one fact — which route this turn goes
888 /// to — and the header rail already renders them as one dotted lockup.
889 #[test]
890 fn status_report_states_the_route_the_way_the_header_does() {
891 let tmpdir = TempDir::new().expect("temp dir");
892 let mut app = create_test_app(tmpdir.path().to_path_buf());
893 let msg = status(&mut app).message.expect("status message");
894 assert!(!msg.contains("Provider:"), "{msg}");
895 assert!(!msg.contains("Model:"), "{msg}");
896 let row = msg
897 .lines()
898 .find(|line| line.trim_start().starts_with("Route:"))
899 .expect("route row");
900 assert!(row.contains(" · "), "route must read as a lockup: {row}");
901 assert!(row.contains("reasoning"), "{row}");
902 }
903
904 /// #5134: the number alone sends users to the issue tracker. `/status` has
905 /// to name the provenance and the key that changes it, and it must name the
906 /// table the user is actually on — not a generic placeholder. The two are
907 /// separate facts, so the key gets its own aligned row instead of a
908 /// parenthesis that pushed the provenance off the end of an 80-column line.
909 #[test]
910 fn status_report_names_context_window_source_and_override_key() {
911 let tmpdir = TempDir::new().expect("temp dir");
912 let mut app = create_test_app(tmpdir.path().to_path_buf());
913 app.set_provider_identity_record(
914 crate::config::Config::default()
915 .resolve_provider_identity(ProviderKind::Moonshot.as_str())
916 .expect("captured fixture provider"),
917 );
918
919 let msg = status(&mut app).message.expect("status message");
920
921 let source_row = msg
922 .lines()
923 .find(|line| line.trim_start().starts_with("Window source:"))
924 .expect("window source row");
925 assert!(
926 !source_row.contains("context_window"),
927 "the provenance row states the provenance only: {source_row}"
928 );
929 // A labelled row, not an indented continuation: the transcript cell
930 // strips leading whitespace, so an aligned continuation line rendered
931 // flush against the label column and read as a field of its own with
932 // the label missing.
933 let override_row = msg
934 .lines()
935 .find(|line| line.trim_start().starts_with("Window override:"))
936 .expect("window override row");
937 assert!(
938 override_row.contains("[providers.moonshot] context_window in config.toml"),
939 "{override_row}"
940 );
941
942 // A user override reads as a statement of fact, not as advice to set
943 // something that is already set.
944 app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
945 let msg = status(&mut app).message.expect("status message");
946 let row = msg
947 .lines()
948 .find(|line| line.trim_start().starts_with("Window source:"))
949 .expect("window source row");
950 assert!(row.contains("configured"), "{row}");
951 assert!(!msg.contains("Window override:"), "{msg}");
952 }
953
954 #[test]
955 fn status_report_keeps_exact_named_custom_provider() {
956 let tmpdir = TempDir::new().expect("temp dir");
957 let mut app = create_test_app(tmpdir.path().to_path_buf());
958 app.set_provider_identity(ProviderKind::Custom, "lm-studio");
959
960 let msg = status(&mut app).message.expect("status message");
961
962 let route_row = msg
963 .lines()
964 .find(|line| line.trim_start().starts_with("Route:"))
965 .expect("route row");
966 assert!(route_row.contains("lm-studio"), "{route_row}");
967 assert!(!route_row.contains("custom"), "{route_row}");
968 }
969
970 #[test]
971 fn status_report_interpolation_preserves_braces_in_runtime_values() {
972 let tmpdir = TempDir::new().expect("temp dir");
973 let mut app = create_test_app(tmpdir.path().to_path_buf());
974 app.set_provider_identity(ProviderKind::Custom, "acme-{model}");
975 app.model = "vision-{reasoning}".to_string();
976 app.current_session_id = Some("session-{cells}-{messages}".to_string());
977
978 let msg = format_status(&app);
979 let route_row = msg
980 .lines()
981 .find(|line| line.trim_start().starts_with("Route:"))
982 .expect("route row");
983 assert!(
984 route_row.contains("acme-{model} · vision-{reasoning} ·"),
985 "{route_row}"
986 );
987 let session_row = msg
988 .lines()
989 .find(|line| line.trim_start().starts_with("Session:"))
990 .expect("session row");
991 assert!(
992 session_row.contains("session-{cells}-{messages}"),
993 "{session_row}"
994 );
995 }
996
997 #[test]
998 fn status_report_surfaces_effective_safety_policy() {
999 let tmpdir = TempDir::new().expect("temp dir");
1000 let mut app = create_test_app(tmpdir.path().to_path_buf());
1001 // `/status` is honest about enforcement: on a platform with no OS
1002 // sandbox (e.g. Windows) it reports "<policy> requested, not enforced"
1003 // instead of the enforced string. The test must hold on both, so it
1004 // branches on the same signal `safety_summary` uses (`sandbox_backend`).
1005 let unenforced = app.sandbox_backend.is_none();
1006
1007 app.mode = AppMode::Agent;
1008 let agent = format_status(&app);
1009 assert!(agent.contains("Safety:"));
1010 if unenforced {
1011 assert!(agent.contains("workspace-write requested, not enforced"));
1012 } else {
1013 // workspace-write no longer implies egress; /status must say so.
1014 assert!(agent.contains("sandbox workspace-write, network off"));
1015 }
1016
1017 app.approval_mode = ApprovalMode::Bypass;
1018 let full_access = format_status(&app);
1019 assert!(full_access.contains("sandbox disabled, network unrestricted"));
1020
1021 app.configured_sandbox_mode = Some("workspace-write".to_string());
1022 let clamped = format_status(&app);
1023 if unenforced {
1024 assert!(clamped.contains("workspace-write requested, not enforced"));
1025 } else {
1026 // Clamping full access down to workspace-write lands on the same
1027 // restricted posture an ordinary Agent turn gets.
1028 assert!(clamped.contains("sandbox workspace-write, network off"));
1029 }
1030
1031 // The explicit opt-in is the only thing that flips the reported label.
1032 app.configured_sandbox_network = Some(true);
1033 let networked = format_status(&app);
1034 if unenforced {
1035 assert!(networked.contains("workspace-write requested, not enforced"));
1036 } else {
1037 assert!(networked.contains("sandbox workspace-write, network on"));
1038 }
1039 app.configured_sandbox_network = None;
1040
1041 app.mode = AppMode::Plan;
1042 let plan = format_status(&app);
1043 if unenforced {
1044 assert!(plan.contains("read-only requested, not enforced"));
1045 } else {
1046 assert!(plan.contains("sandbox read-only, network off"));
1047 }
1048
1049 app.configured_sandbox_mode = None;
1050 app.mode = AppMode::Agent;
1051 let yolo = format_status(&app);
1052 assert!(yolo.contains("sandbox disabled, network unrestricted"));
1053 }
1054
1055 #[test]
1056 fn status_safety_row_discloses_no_new_privs_flag_state_for_full_access() {
1057 // #5723: both flag states get a distinct, truthful row; a platform
1058 // without the flag keeps the plain full-access label. The live query
1059 // is host-dependent, so the selector is pinned directly.
1060 let blocked = tr(Locale::En, safety_disabled_message(Some(true)));
1061 assert!(
1062 blocked.contains("sandbox disabled, network unrestricted"),
1063 "{blocked}"
1064 );
1065 assert!(blocked.contains("sudo/setuid blocked"), "{blocked}");
1066
1067 let relaxed = tr(Locale::En, safety_disabled_message(Some(false)));
1068 assert!(
1069 relaxed.contains("sandbox disabled, network unrestricted"),
1070 "{relaxed}"
1071 );
1072 assert!(relaxed.contains("sudo/setuid allowed"), "{relaxed}");
1073
1074 let plain = safety_disabled_message(None);
1075 assert_eq!(
1076 tr(Locale::En, plain),
1077 tr(Locale::En, MessageId::StatusSafetyDisabled)
1078 );
1079
1080 // The disclosure is real prose, so every complete pack must carry a
1081 // translation rather than a copy of the English string.
1082 for id in [
1083 safety_disabled_message(Some(true)),
1084 safety_disabled_message(Some(false)),
1085 ] {
1086 assert_ne!(tr(Locale::Ja, id), tr(Locale::En, id), "{id:?}");
1087 }
1088 }
1089
1090 #[test]
1091 fn status_report_surfaces_large_tool_output_pressure() {
1092 let tmpdir = TempDir::new().expect("temp dir");
1093 let mut app = create_test_app(tmpdir.path().to_path_buf());
1094 let raw = "RAW_STATUS_PRESSURE\n".repeat(2_000);
1095 app.api_messages_mut().push(Message {
1096 role: Role::User,
1097 content: vec![ContentBlock::ToolResult {
1098 execution_id: None,
1099 tool_use_id: "call-big".to_string(),
1100 content: raw,
1101 is_error: None,
1102 content_blocks: None,
1103 }],
1104 });
1105 app.session_artifacts
1106 .push(crate::artifacts::ArtifactRecord {
1107 id: "art_call-big".to_string(),
1108 kind: crate::artifacts::ArtifactKind::ToolOutput,
1109 session_id: "session-123".to_string(),
1110 tool_call_id: "call-big".to_string(),
1111 tool_name: "exec_shell".to_string(),
1112 created_at: chrono::Utc::now(),
1113 byte_size: 24_000,
1114 preview: "large output".to_string(),
1115 storage_path: PathBuf::from("artifacts/art_call-big.txt"),
1116 });
1117
1118 let result = status(&mut app);
1119 let msg = result.message.expect("status message");
1120
1121 assert!(msg.contains("Tool outputs:"));
1122 assert!(msg.contains("raw over cap"));
1123 assert!(msg.contains("context pressure"));
1124 assert!(msg.contains("artifact"));
1125 }
1126
1127 #[test]
1128 fn status_report_localizes_the_complete_japanese_surface() {
1129 let tmpdir = TempDir::new().expect("temp dir");
1130 let mut app = create_test_app(tmpdir.path().to_path_buf());
1131 app.ui_locale = Locale::Ja;
1132 app.approval_mode = ApprovalMode::Bypass;
1133 app.active_context_window_source =
1134 crate::route_runtime::ContextWindowSource::ProviderReported;
1135
1136 let msg = format_status(&app);
1137
1138 for id in [
1139 MessageId::StatusLabelRoute,
1140 MessageId::StatusLabelDirectory,
1141 MessageId::StatusLabelProjectDocs,
1142 MessageId::StatusLabelMode,
1143 MessageId::StatusLabelSafety,
1144 MessageId::StatusLabelContextWindow,
1145 MessageId::StatusLabelWindowSource,
1146 MessageId::StatusLabelWindowOverride,
1147 MessageId::StatusLabelSession,
1148 MessageId::StatusLabelSessionTokens,
1149 MessageId::StatusLabelSessionCost,
1150 MessageId::StatusLabelToolOutputs,
1151 MessageId::StatusProjectDocsNone,
1152 MessageId::StatusContextSourceProviderReported,
1153 MessageId::StatusSessionNotSaved,
1154 MessageId::StatusToolNone,
1155 MessageId::StatusSafetyDisabled,
1156 ] {
1157 let japanese = tr(Locale::Ja, id);
1158 assert_ne!(japanese, tr(Locale::En, id), "{id:?} copied English");
1159 assert!(msg.contains(japanese.as_ref()), "missing {id:?}: {msg}");
1160 }
1161
1162 for english in [
1163 "Route:",
1164 "Directory:",
1165 "Project docs:",
1166 "Mode:",
1167 "Safety:",
1168 "Context window:",
1169 "Window source:",
1170 "Window override:",
1171 "Session:",
1172 "Session tokens:",
1173 "Session cost:",
1174 "Tool outputs:",
1175 "reasoning ",
1176 "no project docs",
1177 "not saved yet",
1178 "no large outputs tracked",
1179 "Per-turn tokens:",
1180 ] {
1181 assert!(
1182 !msg.contains(english),
1183 "English leaked as {english:?}: {msg}"
1184 );
1185 }
1186
1187 // Protocol/config identities and commands remain literal inside the
1188 // translated prose.
1189 for literal in [
1190 "deepseek",
1191 "context_window",
1192 "config.toml",
1193 "/tokens",
1194 "/statusline",
1195 ] {
1196 assert!(msg.contains(literal), "missing literal {literal:?}: {msg}");
1197 }
1198 }
1199
1200 #[test]
1201 fn project_docs_reports_missing_docs() {
1202 let tmpdir = TempDir::new().expect("temp dir");
1203 assert_eq!(project_docs(tmpdir.path(), Locale::En), "no project docs");
1204 }
1205 }
1206
1206 lines RUST