From ac4396674b01821ed4527ae07a6bfa04ebf11401 Mon Sep 17 00:00:00 2001 From: xodapi <4956501+xodapi@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:54:32 +0700 Subject: [PATCH] fix(cli): align stale and offline semantics (closes #151) --- README.md | 6 +++++ README.ru.md | 6 +++++ src/cli/mcp.rs | 1 + src/cli/monitor/render.rs | 4 +-- src/cli/monitor/tests.rs | 22 +++++++++++++++++ src/cli/output.rs | 51 +++++++++++++++++++++++++++++++++------ src/lib.rs | 36 ++++++++++++++++++++++++--- 7 files changed, 113 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ccb2a35..a759543 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,12 @@ private prompts. - **GUI** (`--features gui`): Slint-based desktop window with stealth toggle, sparklines, account dropdown, informative system tray tooltip with exact usage percentages on hover, proper taskbar application icon on Windows, updates configuration panel, and floating creature overlay. - **Safe by design**: API key from env only, never logged, no telemetry. +## Stale Cache vs Offline API + +- `stale cache` means `vimit` could not fetch fresh `/v1/me` data and is showing the last cached snapshot instead. +- `offline API` means the live API is currently unreachable; when cache fallback is available, output can show both states at once. +- JSON exposes this explicitly with `data_status`, `api_status`, `stale`, and `offline_duration_min`. + ## Download Release binaries: diff --git a/README.ru.md b/README.ru.md index 7fd92f7..7ced717 100644 --- a/README.ru.md +++ b/README.ru.md @@ -61,6 +61,12 @@ vimit --doctor # диагностика системы - **GUI** (`--features gui`): desktop-приложение на Slint с графиками трендов, выпадающим списком аккаунтов, информативным тултипом в системном трее (с точным расходом квоты в процентах на mouse hover), полноценной иконкой приложения на панели задач Windows, разделом управления обновлениями и floating overlay с живым существом. - **Безопасно**: API-ключ только из env, не логируется, нет телеметрии. +## Stale Cache и Offline API + +- `stale cache` означает, что `vimit` не смог получить свежий `/v1/me` и показывает последний сохранённый кэш. +- `offline API` означает, что live API сейчас недоступен; если есть cache fallback, в выводе могут одновременно присутствовать оба состояния. +- В JSON это явно отражено через `data_status`, `api_status`, `stale` и `offline_duration_min`. + ## Диагностика Проверка здоровья системы: diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index e30e4be..dea8fab 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -190,6 +190,7 @@ impl VimitMcpServer { snapshot.stale, snapshot.latency_ms, &snapshot.api_endpoint, + snapshot.offline_duration_min, ); if let Some(map) = status.as_object_mut() { map.insert( diff --git a/src/cli/monitor/render.rs b/src/cli/monitor/render.rs index a370150..90a3456 100644 --- a/src/cli/monitor/render.rs +++ b/src/cli/monitor/render.rs @@ -153,7 +153,7 @@ fn draw_header( } else { "OK" }; - let stale_tag = if s.stale { " STALE" } else { "" }; + let stale_tag = if s.stale { " STALE-CACHE" } else { "" }; let latency_tag = if s.latency_ms > 0 { format!(" {}ms", s.latency_ms) } else { @@ -170,7 +170,7 @@ fn draw_header( let mut final_style = pal.bold_level_style(level); if let Some(min) = s.offline_duration_min { - title = format!(" ⚠ API offline {min}m "); + title.push_str(&format!("| API offline {min}m ")); final_style = pal.danger.into(); } diff --git a/src/cli/monitor/tests.rs b/src/cli/monitor/tests.rs index 9dd40f6..3d13c52 100644 --- a/src/cli/monitor/tests.rs +++ b/src/cli/monitor/tests.rs @@ -112,6 +112,28 @@ fn monitor_output_has_dashboard_sections() { assert!(rendered.contains("codex")); } +#[test] +fn tui_header_shows_stale_cache_and_offline_separately() { + let mut snapshot = test_snapshot(); + snapshot.stale = true; + snapshot.offline_duration_min = Some(4); + let rendered = render_tui_to_string( + Some(&snapshot), + None, + 100, + 30, + true, + 75.0, + &HashMap::new(), + Preset::Full, + 220, + 32, + ); + + assert!(rendered.contains("STALE-CACHE")); + assert!(rendered.contains("API offline 4m")); +} + #[test] fn hbar_renders_correctly() { assert_eq!(hbar(50.0, 10), "[#####-----]"); diff --git a/src/cli/output.rs b/src/cli/output.rs index 730e893..3db79c7 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -28,6 +28,7 @@ pub fn run_once( snapshot.stale, snapshot.latency_ms, &snapshot.api_endpoint, + snapshot.offline_duration_min, ); match args.output { @@ -41,11 +42,15 @@ pub fn run_once( OutputMode::Compact => print_compact( &snapshot.windows, snapshot.abtop.as_ref(), + snapshot.stale, + snapshot.offline_duration_min, + ), + OutputMode::Human => print_human( + &snapshot.windows, + snapshot.abtop.as_ref(), + snapshot.stale, snapshot.offline_duration_min, ), - OutputMode::Human => { - print_human(&snapshot.windows, snapshot.abtop.as_ref(), snapshot.stale) - } } notifier.check_windows(&snapshot.windows); @@ -76,9 +81,13 @@ fn color_percent(percent: f64) -> String { format!("\x1b[{code}m{:.0}%\x1b[0m", percent) } -pub fn print_human(windows: &[ng::WindowState], abtop: Option<&Value>, stale: bool) { - let tag = if stale { " (cached)" } else { "" }; - println!("VibeMode limits{tag}"); +pub fn print_human( + windows: &[ng::WindowState], + abtop: Option<&Value>, + stale: bool, + offline_min: Option, +) { + println!("VibeMode limits{}", human_status_suffix(stale, offline_min)); if windows.is_empty() { println!(" usage rows not found in /v1/me response"); } @@ -114,8 +123,16 @@ pub fn print_human(windows: &[ng::WindowState], abtop: Option<&Value>, stale: bo } } -pub fn print_compact(windows: &[ng::WindowState], abtop: Option<&Value>, offline_min: Option) { +pub fn print_compact( + windows: &[ng::WindowState], + abtop: Option<&Value>, + stale: bool, + offline_min: Option, +) { let mut parts = vec!["NG".to_string()]; + if stale { + parts.push("stale-cache".to_string()); + } if let Some(min) = offline_min { parts.push(format!("offline:{}m", min)); } @@ -141,6 +158,15 @@ pub fn print_compact(windows: &[ng::WindowState], abtop: Option<&Value>, offline println!("{}", parts.join(" ")); } +fn human_status_suffix(stale: bool, offline_min: Option) -> String { + match (stale, offline_min) { + (true, Some(min)) => format!(" (stale cache; API offline {min}m)"), + (true, None) => " (stale cache)".to_string(), + (false, Some(min)) => format!(" (API offline {min}m)"), + (false, None) => String::new(), + } +} + fn format_agent(agent: &Value) -> String { let agent_cli = agent .get("agent_cli") @@ -322,4 +348,15 @@ mod tests { assert_eq!(escaped, "danger 95%25%0Anext%0Dline"); } + + #[test] + fn human_status_suffix_separates_stale_and_offline() { + assert_eq!(human_status_suffix(false, None), ""); + assert_eq!(human_status_suffix(true, None), " (stale cache)"); + assert_eq!(human_status_suffix(false, Some(3)), " (API offline 3m)"); + assert_eq!( + human_status_suffix(true, Some(3)), + " (stale cache; API offline 3m)" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index d235192..bdec256 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -481,6 +481,7 @@ pub fn summary_to_json_with_stale( stale: bool, latency_ms: u64, active_endpoint: &str, + offline_duration_min: Option, ) -> Value { let mut obj = json!({ "source": "vibemode", @@ -494,9 +495,11 @@ pub fn summary_to_json_with_stale( })), }); if let Some(map) = obj.as_object_mut() { - if stale { - map.insert("stale".to_string(), Value::Bool(true)); - } + map.insert("stale".to_string(), Value::Bool(stale)); + map.insert( + "data_status".to_string(), + Value::String(if stale { "stale_cache" } else { "live" }.to_string()), + ); map.insert( "latency_ms".to_string(), Value::Number(serde_json::Number::from(latency_ms)), @@ -505,7 +508,7 @@ pub fn summary_to_json_with_stale( "active_endpoint".to_string(), Value::String(active_endpoint.to_string()), ); - if let Some(offline_mins) = get_offline_duration_min() { + if let Some(offline_mins) = offline_duration_min { map.insert( "api_status".to_string(), Value::String("offline".to_string()), @@ -520,6 +523,10 @@ pub fn summary_to_json_with_stale( Value::String("online".to_string()), ); } + map.insert( + "degraded".to_string(), + Value::Bool(stale || offline_duration_min.is_some()), + ); } obj } @@ -775,6 +782,27 @@ mod tests { assert!(!encoded.contains("usr_demo")); } + #[test] + fn stale_and_offline_json_semantics_are_explicit() { + let windows = summarize_me(&demo_payload(), 75.0, 90.0); + let encoded = summary_to_json_with_stale(&windows, None, None, true, 123, "api", Some(4)); + + assert_eq!(encoded.get("stale"), Some(&Value::Bool(true))); + assert_eq!( + encoded.get("data_status"), + Some(&Value::String("stale_cache".to_string())) + ); + assert_eq!( + encoded.get("api_status"), + Some(&Value::String("offline".to_string())) + ); + assert_eq!(encoded.get("degraded"), Some(&Value::Bool(true))); + assert_eq!( + encoded.get("offline_duration_min"), + Some(&Value::Number(serde_json::Number::from(4_u64))) + ); + } + #[test] fn parses_dotenv_without_leaking_comments() { let parsed = parse_dotenv(