返回 CodeWhale
status.rs
根目录 / crates / tui / src / commands / groups / config / status.rs
1 //! Runtime status command.
2
3 use std::fmt::Write as _;
4 use std::path::Path;
5
6 use super::CommandResult;
7 use crate::compaction::estimate_input_tokens_conservative;
8 use crate::tui::app::App;
9 use crate::utils::{display_path, estimate_message_chars};
10
11 /// Show a compact runtime status report for the current TUI session.
12 pub fn status(app: &mut App) -> CommandResult {
13 CommandResult::message(format_status(app))
14 }
15
16 fn format_status(app: &App) -> String {
17 let mut out = String::new();
18 let (context_used, context_max, context_percent) = context_usage(app);
19
20 let _ = writeln!(out, "codewhale Status");
21 let _ = writeln!(out, "===================");
22 let _ = writeln!(out);
23 push_row(&mut out, "Version:", env!("CARGO_PKG_VERSION"));
24 push_row(
25 &mut out,
26 "Provider:",
27 app.provider_identity_for_persistence(),
28 );
29 push_row(
30 &mut out,
31 "Model:",
32 &format!(
33 "{} (reasoning {})",
34 app.model_display_label(),
35 app.reasoning_effort_display_label()
36 ),
37 );
38 push_row(&mut out, "Directory:", &display_path(&app.workspace));
39 push_row(&mut out, "Mode:", app.mode.label());
40 push_row(&mut out, "Permissions:", &permission_summary(app));
41 push_row(&mut out, "Safety:", safety_summary(app));
42 push_row(&mut out, "Project docs:", &project_docs(&app.workspace));
43 push_row(
44 &mut out,
45 "Session:",
46 app.current_session_id.as_deref().unwrap_or("not saved yet"),
47 );
48 push_row(
49 &mut out,
50 "MCP:",
51 &format!("{} configured", app.mcp_configured_count),
52 );
53 push_row(&mut out, "Footer items:", &footer_items(app));
54 let _ = writeln!(out);
55 push_row(
56 &mut out,
57 "Context window:",
58 &format!("{context_percent:.1}% used ({context_used} / {context_max} tokens)"),
59 );
60 push_row(&mut out, "Window source:", &context_window_source(app));
61 push_row(
62 &mut out,
63 "Last API input:",
64 &token_count(app.session.last_prompt_tokens),
65 );
66 push_row(
67 &mut out,
68 "Last API output:",
69 &token_count(app.session.last_completion_tokens),
70 );
71 push_row(&mut out, "Cache hit/miss:", &cache_summary(app));
72 push_row(
73 &mut out,
74 "Session input:",
75 &app.session.total_input_tokens.to_string(),
76 );
77 let session_cache =
78 if app.session.total_cache_hit_tokens == 0 && app.session.total_cache_miss_tokens == 0 {
79 "not reported".to_string()
80 } else {
81 format!(
82 "{} hit / {} miss",
83 app.session.total_cache_hit_tokens, app.session.total_cache_miss_tokens
84 )
85 };
86 push_row(&mut out, "Session cache:", &session_cache);
87 push_row(
88 &mut out,
89 "Session output:",
90 &app.session.total_output_tokens.to_string(),
91 );
92 push_row(
93 &mut out,
94 "Total tokens:",
95 &app.session.total_tokens.to_string(),
96 );
97 push_row(
98 &mut out,
99 "Session cost:",
100 &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)),
101 );
102 push_row(
103 &mut out,
104 "Transcript:",
105 &format!(
106 "{} cells, {} API messages",
107 app.history.len(),
108 app.api_messages.len()
109 ),
110 );
111 let tool_output_status =
112 crate::tool_output_receipts::tool_output_status(&app.api_messages, &app.session_artifacts);
113 push_row(
114 &mut out,
115 "Tool outputs:",
116 &crate::tool_output_receipts::format_tool_output_status(&tool_output_status),
117 );
118 push_row(
119 &mut out,
120 "Rate limits:",
121 "not available from provider telemetry",
122 );
123 let _ = writeln!(out);
124 let _ = writeln!(out, "Use /statusline to configure footer items.");
125
126 out
127 }
128
129 fn push_row(out: &mut String, label: &str, value: &str) {
130 let _ = writeln!(out, " {label:<16} {value}");
131 }
132
133 fn permission_summary(app: &App) -> String {
134 let trust = if app.trust_mode {
135 "trusted workspace"
136 } else {
137 "workspace"
138 };
139 let shell = if app.allow_shell {
140 "shell on"
141 } else {
142 "shell off"
143 };
144 format!(
145 "{trust}, approvals {}, {shell}",
146 app.approval_mode
147 .permission_chip_label()
148 .to_ascii_lowercase()
149 )
150 }
151
152 fn safety_summary(app: &App) -> &'static str {
153 let policy = crate::core::authority::sandbox_policy_for_turn(
154 app.mode,
155 app.approval_mode,
156 app.configured_sandbox_mode.as_deref(),
157 &app.workspace,
158 );
159 // The policy is the intent; `sandbox_backend` is what this platform can
160 // actually enforce with. Default Linux (bubblewrap is opt-in) and all
161 // Windows have none, and /status used to report "sandbox workspace-write"
162 // while nothing was restricted (2026-08-04 audit). `doctor` has always
163 // been honest about this; /status now agrees with it.
164 let unenforced = app.sandbox_backend.is_none();
165 match policy {
166 crate::sandbox::SandboxPolicy::ReadOnly if unenforced => {
167 "no OS sandbox on this platform (read-only requested, not enforced), network off"
168 }
169 crate::sandbox::SandboxPolicy::ReadOnly => "sandbox read-only, network off",
170 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } if unenforced => {
171 "no OS sandbox on this platform (workspace-write requested, not enforced), network on"
172 }
173 crate::sandbox::SandboxPolicy::WorkspaceWrite { .. } => {
174 "sandbox workspace-write, network on"
175 }
176 crate::sandbox::SandboxPolicy::DangerFullAccess => "sandbox disabled, network unrestricted",
177 crate::sandbox::SandboxPolicy::ExternalSandbox { .. } => {
178 "external sandbox, network delegated to host"
179 }
180 }
181 }
182
183 fn project_docs(workspace: &Path) -> String {
184 let docs: Vec<&str> = ["AGENTS.md", "CLAUDE.md"]
185 .into_iter()
186 .filter(|name| workspace.join(name).is_file())
187 .collect();
188 if docs.is_empty() {
189 "not found".to_string()
190 } else {
191 docs.join(", ")
192 }
193 }
194
195 fn footer_items(app: &App) -> String {
196 if app.status_items.is_empty() {
197 return "none".to_string();
198 }
199 app.status_items
200 .iter()
201 .map(|item| item.key())
202 .collect::<Vec<_>>()
203 .join(", ")
204 }
205
206 fn context_usage(app: &App) -> (usize, u32, f64) {
207 let max = crate::route_budget::route_context_window_tokens(
208 app.api_provider,
209 app.effective_model_for_budget(),
210 app.active_route_limits,
211 );
212 let estimated =
213 estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
214 let total_chars = estimate_message_chars(&app.api_messages);
215 let used = estimated.max(total_chars / 4);
216 let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0);
217 (used, max, percent)
218 }
219
220 /// Name where the effective context window came from and the exact key that
221 /// changes it.
222 ///
223 /// #5134: `/status` printed the window as a bare number, so a user watching
224 /// auto-compaction fire at 128K on a 1M-capable model had no way to learn that
225 /// `context_window` exists, let alone which table it belongs on. The
226 /// provenance label alone is not enough — the actionable half is the key path.
227 fn context_window_source(app: &App) -> String {
228 let source = app.active_context_window_source;
229 let label = source.label();
230 let Some(table) = app
231 .api_provider
232 .metadata()
233 .map(|metadata| metadata.provider_config_key())
234 else {
235 return format!(
236 "{label} (override: `context_window` on the active provider table in config.toml)"
237 );
238 };
239 if source == crate::route_runtime::ContextWindowSource::Configured {
240 format!("{label} — `[providers.{table}] context_window` in config.toml")
241 } else {
242 format!("{label} (override: `[providers.{table}] context_window` in config.toml)")
243 }
244 }
245
246 fn token_count(value: Option<u32>) -> String {
247 value.map_or_else(|| "not reported".to_string(), |tokens| tokens.to_string())
248 }
249
250 fn cache_summary(app: &App) -> String {
251 match (
252 app.session.last_prompt_cache_hit_tokens,
253 app.session.last_prompt_cache_miss_tokens,
254 ) {
255 (Some(hit), Some(miss)) => format!("{hit} hit / {miss} miss"),
256 (Some(hit), None) => format!("{hit} hit / miss not reported"),
257 (None, Some(miss)) => format!("hit not reported / {miss} miss"),
258 (None, None) => "not reported".to_string(),
259 }
260 }
261
262 #[cfg(test)]
263 mod tests {
264 use std::path::PathBuf;
265
266 use tempfile::TempDir;
267
268 use super::*;
269 use crate::config::{ApiProvider, Config};
270 use crate::models::{ContentBlock, Message};
271 use crate::tui::app::{AppMode, TuiOptions};
272 use crate::tui::history::HistoryCell;
273
274 fn create_test_app(workspace: PathBuf) -> App {
275 let options = TuiOptions {
276 skills_dir: PathBuf::from("/tmp/test-skills"),
277 ..crate::test_support::test_tui_options(workspace)
278 };
279 let mut app = App::new(options, &Config::default());
280 app.api_provider = ApiProvider::Deepseek;
281 app
282 }
283
284 #[test]
285 fn status_report_includes_runtime_fields() {
286 let tmpdir = TempDir::new().expect("temp dir");
287 std::fs::write(tmpdir.path().join("AGENTS.md"), "# Instructions").expect("write docs");
288 let mut app = create_test_app(tmpdir.path().to_path_buf());
289 app.current_session_id = Some("session-123".to_string());
290 app.session.total_tokens = 1234;
291 app.session.last_prompt_tokens = Some(100);
292 app.session.last_completion_tokens = Some(25);
293 app.session.last_prompt_cache_hit_tokens = Some(70);
294 app.session.last_prompt_cache_miss_tokens = Some(30);
295 app.api_messages.push(Message {
296 role: "user".to_string(),
297 content: vec![ContentBlock::Text {
298 text: "hello".to_string(),
299 cache_control: None,
300 }],
301 });
302 app.history.push(HistoryCell::User {
303 content: "hello".to_string(),
304 });
305
306 let result = status(&mut app);
307 let msg = result.message.expect("status message");
308 assert!(msg.contains("codewhale Status"));
309 assert!(msg.contains("Provider:"));
310 assert!(msg.contains("Model:"));
311 assert!(msg.contains("Directory:"));
312 assert!(msg.contains("Permissions:"));
313 assert!(msg.contains("Project docs:"));
314 assert!(msg.contains("AGENTS.md"));
315 assert!(msg.contains("Session:"));
316 assert!(msg.contains("session-123"));
317 assert!(msg.contains("Context window:"));
318 assert!(msg.contains("Tool outputs:"));
319 assert!(msg.contains("Cache hit/miss:"));
320 assert!(msg.contains("70 hit / 30 miss"));
321 assert!(msg.contains("Use /statusline to configure footer items."));
322 }
323
324 /// #5134: the number alone sends users to the issue tracker. `/status` has
325 /// to name the provenance and the key that changes it, and it must name the
326 /// table the user is actually on — not a generic placeholder.
327 #[test]
328 fn status_report_names_context_window_source_and_override_key() {
329 let tmpdir = TempDir::new().expect("temp dir");
330 let mut app = create_test_app(tmpdir.path().to_path_buf());
331 app.api_provider = ApiProvider::Moonshot;
332
333 let msg = status(&mut app).message.expect("status message");
334
335 let row = msg
336 .lines()
337 .find(|line| line.trim_start().starts_with("Window source:"))
338 .expect("window source row");
339 assert!(
340 row.contains("[providers.moonshot] context_window"),
341 "override key must be spelled for the active provider: {row}"
342 );
343
344 // A user override reads as a statement of fact, not as advice to set
345 // something that is already set.
346 app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
347 let msg = status(&mut app).message.expect("status message");
348 let row = msg
349 .lines()
350 .find(|line| line.trim_start().starts_with("Window source:"))
351 .expect("window source row");
352 assert!(row.contains("configured"), "{row}");
353 assert!(!row.contains("override:"), "{row}");
354 }
355
356 #[test]
357 fn status_report_keeps_exact_named_custom_provider() {
358 let tmpdir = TempDir::new().expect("temp dir");
359 let mut app = create_test_app(tmpdir.path().to_path_buf());
360 app.set_provider_identity(ApiProvider::Custom, "lm-studio");
361
362 let msg = status(&mut app).message.expect("status message");
363
364 let provider_row = msg
365 .lines()
366 .find(|line| line.trim_start().starts_with("Provider:"))
367 .expect("provider row");
368 assert_eq!(provider_row.split_whitespace().last(), Some("lm-studio"));
369 assert_ne!(provider_row.split_whitespace().last(), Some("custom"));
370 }
371
372 #[test]
373 fn status_report_surfaces_effective_safety_policy() {
374 let tmpdir = TempDir::new().expect("temp dir");
375 let mut app = create_test_app(tmpdir.path().to_path_buf());
376 // `/status` is honest about enforcement: on a platform with no OS
377 // sandbox (e.g. Windows) it reports "<policy> requested, not enforced"
378 // instead of the enforced string. The test must hold on both, so it
379 // branches on the same signal `safety_summary` uses (`sandbox_backend`).
380 let unenforced = app.sandbox_backend.is_none();
381
382 app.mode = AppMode::Agent;
383 let agent = format_status(&app);
384 assert!(agent.contains("Safety:"));
385 if unenforced {
386 assert!(agent.contains("workspace-write requested, not enforced"));
387 } else {
388 assert!(agent.contains("sandbox workspace-write, network on"));
389 }
390
391 app.approval_mode = crate::tui::approval::ApprovalMode::Bypass;
392 let full_access = format_status(&app);
393 assert!(full_access.contains("sandbox disabled, network unrestricted"));
394
395 app.configured_sandbox_mode = Some("workspace-write".to_string());
396 let clamped = format_status(&app);
397 if unenforced {
398 assert!(clamped.contains("workspace-write requested, not enforced"));
399 } else {
400 assert!(clamped.contains("sandbox workspace-write, network on"));
401 }
402
403 app.mode = AppMode::Plan;
404 let plan = format_status(&app);
405 if unenforced {
406 assert!(plan.contains("read-only requested, not enforced"));
407 } else {
408 assert!(plan.contains("sandbox read-only, network off"));
409 }
410
411 app.configured_sandbox_mode = None;
412 app.mode = AppMode::Yolo;
413 let yolo = format_status(&app);
414 assert!(yolo.contains("sandbox disabled, network unrestricted"));
415 }
416
417 #[test]
418 fn status_report_surfaces_large_tool_output_pressure() {
419 let tmpdir = TempDir::new().expect("temp dir");
420 let mut app = create_test_app(tmpdir.path().to_path_buf());
421 let raw = "RAW_STATUS_PRESSURE\n".repeat(2_000);
422 app.api_messages.push(Message {
423 role: "user".to_string(),
424 content: vec![ContentBlock::ToolResult {
425 tool_use_id: "call-big".to_string(),
426 content: raw,
427 is_error: None,
428 content_blocks: None,
429 }],
430 });
431 app.session_artifacts
432 .push(crate::artifacts::ArtifactRecord {
433 id: "art_call-big".to_string(),
434 kind: crate::artifacts::ArtifactKind::ToolOutput,
435 session_id: "session-123".to_string(),
436 tool_call_id: "call-big".to_string(),
437 tool_name: "exec_shell".to_string(),
438 created_at: chrono::Utc::now(),
439 byte_size: 24_000,
440 preview: "large output".to_string(),
441 storage_path: PathBuf::from("artifacts/art_call-big.txt"),
442 });
443
444 let result = status(&mut app);
445 let msg = result.message.expect("status message");
446
447 assert!(msg.contains("Tool outputs:"));
448 assert!(msg.contains("raw over cap"));
449 assert!(msg.contains("context pressure"));
450 assert!(msg.contains("artifact"));
451 }
452
453 #[test]
454 fn project_docs_reports_missing_docs() {
455 let tmpdir = TempDir::new().expect("temp dir");
456 assert_eq!(project_docs(tmpdir.path()), "not found");
457 }
458 }
459
459 lines RUST