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