diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 0765e34d..b60a0e93 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -26,6 +26,7 @@ mod session; pub mod share; mod skills; mod stash; +mod status; mod task; mod user_commands; @@ -420,9 +421,15 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/settings", description_id: MessageId::CmdSettingsDescription, }, + CommandInfo { + name: "status", + aliases: &[], + usage: "/status", + description_id: MessageId::CmdStatusDescription, + }, CommandInfo { name: "statusline", - aliases: &["status"], + aliases: &[], usage: "/statusline", description_id: MessageId::CmdStatuslineDescription, }, @@ -531,7 +538,8 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { // Config commands "config" => config::config_command(app, arg), "settings" => config::show_settings(app), - "statusline" | "status" => config::status_line(app), + "status" => status::status(app), + "statusline" => config::status_line(app), "mode" => config::mode(app, arg), "theme" => config::theme(app), "verbose" => config::verbose(app, arg), diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/status.rs new file mode 100644 index 00000000..55e84cdc --- /dev/null +++ b/crates/tui/src/commands/status.rs @@ -0,0 +1,250 @@ +//! Runtime status command. + +use std::fmt::Write as _; +use std::path::Path; + +use super::CommandResult; +use crate::compaction::estimate_input_tokens_conservative; +use crate::models::{LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS, context_window_for_model}; +use crate::tui::app::App; +use crate::utils::{display_path, estimate_message_chars}; + +/// Show a compact runtime status report for the current TUI session. +pub fn status(app: &mut App) -> CommandResult { + CommandResult::message(format_status(app)) +} + +fn format_status(app: &App) -> String { + let mut out = String::new(); + let (context_used, context_max, context_percent) = context_usage(app); + + let _ = writeln!(out, "DeepSeek TUI Status"); + let _ = writeln!(out, "==================="); + let _ = writeln!(out); + push_row(&mut out, "Version:", env!("CARGO_PKG_VERSION")); + push_row(&mut out, "Provider:", app.api_provider.as_str()); + push_row( + &mut out, + "Model:", + &format!( + "{} (reasoning {})", + app.model_display_label(), + app.reasoning_effort_display_label() + ), + ); + push_row(&mut out, "Directory:", &display_path(&app.workspace)); + push_row(&mut out, "Mode:", app.mode.label()); + push_row(&mut out, "Permissions:", &permission_summary(app)); + push_row(&mut out, "Project docs:", &project_docs(&app.workspace)); + push_row( + &mut out, + "Session:", + app.current_session_id.as_deref().unwrap_or("not saved yet"), + ); + push_row( + &mut out, + "MCP:", + &format!("{} configured", app.mcp_configured_count), + ); + push_row(&mut out, "Footer items:", &footer_items(app)); + let _ = writeln!(out); + push_row( + &mut out, + "Context window:", + &format!("{context_percent:.1}% used ({context_used} / {context_max} tokens)"), + ); + push_row( + &mut out, + "Last API input:", + &token_count(app.session.last_prompt_tokens), + ); + push_row( + &mut out, + "Last API output:", + &token_count(app.session.last_completion_tokens), + ); + push_row(&mut out, "Cache hit/miss:", &cache_summary(app)); + push_row( + &mut out, + "Total tokens:", + &app.session.total_tokens.to_string(), + ); + push_row( + &mut out, + "Session cost:", + &app.format_cost_amount_precise(app.session_cost_for_currency(app.cost_currency)), + ); + push_row( + &mut out, + "Transcript:", + &format!( + "{} cells, {} API messages", + app.history.len(), + app.api_messages.len() + ), + ); + push_row( + &mut out, + "Rate limits:", + "not available from provider telemetry", + ); + let _ = writeln!(out); + let _ = writeln!(out, "Use /statusline to configure footer items."); + + out +} + +fn push_row(out: &mut String, label: &str, value: &str) { + let _ = writeln!(out, " {label:<16} {value}"); +} + +fn permission_summary(app: &App) -> String { + let trust = if app.trust_mode { + "trusted workspace" + } else { + "workspace" + }; + let shell = if app.allow_shell { + "shell on" + } else { + "shell off" + }; + format!( + "{trust}, approvals {}, {shell}", + app.approval_mode.label().to_ascii_lowercase() + ) +} + +fn project_docs(workspace: &Path) -> String { + let docs: Vec<&str> = ["AGENTS.md", "CLAUDE.md"] + .into_iter() + .filter(|name| workspace.join(name).is_file()) + .collect(); + if docs.is_empty() { + "not found".to_string() + } else { + docs.join(", ") + } +} + +fn footer_items(app: &App) -> String { + if app.status_items.is_empty() { + return "none".to_string(); + } + app.status_items + .iter() + .map(|item| item.key()) + .collect::>() + .join(", ") +} + +fn context_usage(app: &App) -> (usize, u32, f64) { + let max = context_window_for_model(&app.model).unwrap_or(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS); + let estimated = + estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref()); + let total_chars = estimate_message_chars(&app.api_messages); + let used = estimated.max(total_chars / 4); + let percent = ((used as f64 / f64::from(max)) * 100.0).clamp(0.0, 100.0); + (used, max, percent) +} + +fn token_count(value: Option) -> String { + value.map_or_else(|| "not reported".to_string(), |tokens| tokens.to_string()) +} + +fn cache_summary(app: &App) -> String { + match ( + app.session.last_prompt_cache_hit_tokens, + app.session.last_prompt_cache_miss_tokens, + ) { + (Some(hit), Some(miss)) => format!("{hit} hit / {miss} miss"), + (Some(hit), None) => format!("{hit} hit / miss not reported"), + (None, Some(miss)) => format!("hit not reported / {miss} miss"), + (None, None) => "not reported".to_string(), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + use crate::config::{ApiProvider, Config}; + use crate::models::{ContentBlock, Message}; + use crate::tui::app::TuiOptions; + use crate::tui::history::HistoryCell; + + fn create_test_app(workspace: PathBuf) -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace, + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("/tmp/test-skills"), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let mut app = App::new(options, &Config::default()); + app.api_provider = ApiProvider::Deepseek; + app + } + + #[test] + fn status_report_includes_runtime_fields() { + let tmpdir = TempDir::new().expect("temp dir"); + std::fs::write(tmpdir.path().join("AGENTS.md"), "# Instructions").expect("write docs"); + let mut app = create_test_app(tmpdir.path().to_path_buf()); + app.current_session_id = Some("session-123".to_string()); + app.session.total_tokens = 1234; + app.session.last_prompt_tokens = Some(100); + app.session.last_completion_tokens = Some(25); + app.session.last_prompt_cache_hit_tokens = Some(70); + app.session.last_prompt_cache_miss_tokens = Some(30); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "hello".to_string(), + cache_control: None, + }], + }); + app.history.push(HistoryCell::User { + content: "hello".to_string(), + }); + + let result = status(&mut app); + let msg = result.message.expect("status message"); + assert!(msg.contains("DeepSeek TUI Status")); + assert!(msg.contains("Provider:")); + assert!(msg.contains("Model:")); + assert!(msg.contains("Directory:")); + assert!(msg.contains("Permissions:")); + assert!(msg.contains("Project docs:")); + assert!(msg.contains("AGENTS.md")); + assert!(msg.contains("Session:")); + assert!(msg.contains("session-123")); + assert!(msg.contains("Context window:")); + assert!(msg.contains("Cache hit/miss:")); + assert!(msg.contains("70 hit / 30 miss")); + assert!(msg.contains("Use /statusline to configure footer items.")); + } + + #[test] + fn project_docs_reports_missing_docs() { + let tmpdir = TempDir::new().expect("temp dir"); + assert_eq!(project_docs(tmpdir.path()), "not found"); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 40bd1078..34f3ad9f 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -259,6 +259,7 @@ pub enum MessageId { CmdSkillDescription, CmdSkillsDescription, CmdStashDescription, + CmdStatusDescription, CmdStatuslineDescription, CmdSubagentsDescription, CmdSwarmDescription, @@ -448,6 +449,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdSkillDescription, MessageId::CmdSkillsDescription, MessageId::CmdStashDescription, + MessageId::CmdStatusDescription, MessageId::CmdStatuslineDescription, MessageId::CmdSubagentsDescription, MessageId::CmdSwarmDescription, @@ -785,6 +787,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdStashDescription => { "Park or restore a composer draft (Ctrl+S to push, /stash list/pop)" } + MessageId::CmdStatusDescription => "Show runtime session status", MessageId::CmdStatuslineDescription => "Configure which items appear in the footer", MessageId::CmdSubagentsDescription => "List sub-agent status", MessageId::CmdSwarmDescription => { @@ -1072,6 +1075,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdStashDescription => { "コンポーザーの下書きを退避/復元(Ctrl+S で退避、/stash list|pop)" } + MessageId::CmdStatusDescription => "実行中のセッション状態を表示", MessageId::CmdStatuslineDescription => "フッターに表示する項目を設定", MessageId::CmdSubagentsDescription => "サブエージェントの状態を一覧表示", MessageId::CmdSwarmDescription => { @@ -1331,6 +1335,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", MessageId::CmdSkillsDescription => "列出本地技能(或使用 --remote 浏览精选注册表)", MessageId::CmdStashDescription => "暂存或恢复输入草稿(Ctrl+S 暂存,/stash list|pop)", + MessageId::CmdStatusDescription => "显示当前运行状态", MessageId::CmdStatuslineDescription => "配置底栏要显示哪些条目", MessageId::CmdSubagentsDescription => "列出子代理状态", MessageId::CmdSwarmDescription => { @@ -1604,6 +1609,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdStashDescription => { "Estacionar ou restaurar rascunho do compositor (Ctrl+S estaciona, /stash list|pop)" } + MessageId::CmdStatusDescription => "Exibir o status da sessão em execução", MessageId::CmdStatuslineDescription => "Configurar quais itens aparecem no rodapé", MessageId::CmdSubagentsDescription => "Listar o status dos sub-agentes", MessageId::CmdSwarmDescription => {