From df0c11cfe4d7420c1a6dc9f6d328c5804d608e2d Mon Sep 17 00:00:00 2001 From: xodapi <4956501+xodapi@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:10:41 +0700 Subject: [PATCH] feat(android): wire stuck burn alert state (closes #109) --- docs/android.md | 6 +++ src/lib.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++++- ui/app.slint | 19 ++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/docs/android.md b/docs/android.md index 04f2451..0ee7d37 100644 --- a/docs/android.md +++ b/docs/android.md @@ -64,9 +64,15 @@ runaway token burn events: - prompt text, paths, session IDs, tool arguments, and API keys are never included in notifications; - repeated runaway events are debounced by `AndroidAlertGate`; +- runaway events set Vimichi into a visible alarm state in the Android UI; +- idle/recovery events clear the alarm state; - Android 8+ receives a `vimit-agent-alerts` notification channel; - Android 13+ still requires the user to grant notification permission at runtime before notifications are visible. Runtime permission UX for Android 13+ should be implemented as a separate Android task before relying on notifications as the only alert channel. + +The Android alert bridge consumes `AgentBurnEvent` from the shared core +detector. The foreground/background polling source for those events is tracked +as a separate Android task. diff --git a/src/lib.rs b/src/lib.rs index 8e003e9..f02b1d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -453,7 +453,7 @@ impl AgentPulse { pub const DEFAULT_STUCK_BURN_MIN_RATE_PER_SEC: f64 = 0.1; pub const DEFAULT_STUCK_BURN_TIMEOUT_SECS: u64 = 90; -pub const DEFAULT_ANDROID_ALERT_COOLDOWN_SECS: u64 = 300; +pub const DEFAULT_ANDROID_ALERT_COOLDOWN_SECS: u64 = 60; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentBurnState { @@ -561,6 +561,61 @@ pub fn android_runaway_alert_text() -> AndroidAlertText { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AndroidBurnUiState { + pub visible: bool, + pub level: &'static str, + pub text: &'static str, + pub creature_state: &'static str, + pub delta_level: &'static str, + pub delta_text: &'static str, +} + +pub fn android_burn_ui_state(event: &AgentBurnEvent) -> AndroidBurnUiState { + match event.state { + AgentBurnState::Runaway => AndroidBurnUiState { + visible: true, + level: "danger", + text: "Vimichi: тревога, токены горят", + creature_state: "critical", + delta_level: "danger", + delta_text: "токены горят", + }, + AgentBurnState::Active => AndroidBurnUiState { + visible: false, + level: "ok", + text: "", + creature_state: "awake", + delta_level: "ok", + delta_text: "агент работает", + }, + AgentBurnState::Recovery => AndroidBurnUiState { + visible: false, + level: "ok", + text: "", + creature_state: "recovery", + delta_level: "ok", + delta_text: "расход остановлен", + }, + AgentBurnState::Idle => AndroidBurnUiState { + visible: false, + level: "ok", + text: "", + creature_state: "sleeping", + delta_level: "ok", + delta_text: "агент простаивает", + }, + AgentBurnState::Unknown => AndroidBurnUiState { + visible: false, + level: "unknown", + text: "", + creature_state: "awake", + delta_level: "ok", + delta_text: "нет данных", + }, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AndroidAlertGate { cooldown_secs: u64, @@ -626,6 +681,28 @@ impl AndroidAlertBridge { } } +#[cfg(all(target_os = "android", feature = "android-gui"))] +pub fn android_handle_burn_event( + app: &AppWindow, + bridge: &AndroidAlertBridge, + event: &AgentBurnEvent, + now_secs: u64, +) -> Result { + android_apply_burn_ui_state(app, event); + bridge.alert_runaway(event, now_secs) +} + +#[cfg(all(target_os = "android", feature = "android-gui"))] +fn android_apply_burn_ui_state(app: &AppWindow, event: &AgentBurnEvent) { + let state = android_burn_ui_state(event); + app.set_agent_alert_visible(state.visible); + app.set_agent_alert_level(state.level.into()); + app.set_agent_alert_text(state.text.into()); + app.set_overlay_creature_state(state.creature_state.into()); + app.set_overlay_delta_level(state.delta_level.into()); + app.set_overlay_delta_text(state.delta_text.into()); +} + #[cfg(all(target_os = "android", feature = "android-gui"))] fn android_vibrate(app: &slint::android::AndroidApp, duration_ms: i64) -> Result<(), String> { let app = app.clone(); @@ -1523,4 +1600,50 @@ mod tests { assert!(!combined.contains("session")); assert!(!combined.contains("c:\\")); } + + #[test] + fn android_burn_ui_state_marks_runaway_as_vimichi_alarm() { + let event = AgentBurnEvent { + state: AgentBurnState::Runaway, + burning_for_secs: Some(90), + token_rate_per_sec: 12.0, + }; + + let state = android_burn_ui_state(&event); + + assert!(state.visible); + assert_eq!(state.level, "danger"); + assert_eq!(state.text, "Vimichi: тревога, токены горят"); + assert_eq!(state.creature_state, "critical"); + assert_eq!(state.delta_level, "danger"); + } + + #[test] + fn android_burn_ui_state_resets_after_recovery() { + let event = AgentBurnEvent { + state: AgentBurnState::Recovery, + burning_for_secs: None, + token_rate_per_sec: 0.0, + }; + + let state = android_burn_ui_state(&event); + + assert!(!state.visible); + assert_eq!(state.creature_state, "recovery"); + assert_eq!(state.delta_text, "расход остановлен"); + } + + #[test] + fn android_alert_gate_default_uses_one_minute_cooldown() { + let mut gate = AndroidAlertGate::default(); + let event = AgentBurnEvent { + state: AgentBurnState::Runaway, + burning_for_secs: Some(90), + token_rate_per_sec: 7.0, + }; + + assert!(gate.should_alert(&event, 1000)); + assert!(!gate.should_alert(&event, 1059)); + assert!(gate.should_alert(&event, 1060)); + } } diff --git a/ui/app.slint b/ui/app.slint index 71a048f..0dfce44 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -465,6 +465,9 @@ export component AppWindow inherits Window { in property source-text: "источник: загрузка"; in property agent-text: "агенты: нет данных"; in property token-rate-text: "токены/мин: нет данных"; + in property agent-alert-visible: false; + in property agent-alert-level: "ok"; + in property agent-alert-text: ""; in property footer-text: ".env: текущая папка, затем рядом с exe"; in-out property settings-open: false; in-out property period-mode: 0; @@ -640,6 +643,22 @@ export component AppWindow inherits Window { font-weight: 600; } + if (root.agent-alert-visible) : Rectangle { + height: 34px; + background: root.agent-alert-level == "danger" ? Theme.danger : Theme.warning; + border-radius: 8px; + + Text { + text: root.agent-alert-text; + color: #ffffff; + font-size: 13px; + font-weight: 800; + horizontal-alignment: center; + vertical-alignment: center; + wrap: word-wrap; + } + } + if (!root.is-android) : HorizontalLayout { spacing: 10px; height: 34px;