返回 CodeWhale
status.rs
根目录 / crates / tui / src / tui / app / status.rs
1 //! Status surface state: toast queue, sticky status, and the
2 //! `status_message` -> toast synchronization helpers.
3 //!
4 //! `StatusToast` / `StatusToastLevel` live here with the `impl App`
5 //! extension block that owns toast/status/message behavior; state fields
6 //! (`status_toasts`, `sticky_status`, `status_message`,
7 //! `last_status_message_seen`) remain on `App` in `app.rs`.
8
9 use super::*;
10
11 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 pub enum StatusToastLevel {
13 Info,
14 Success,
15 Warning,
16 Error,
17 }
18
19 impl StatusToastLevel {
20 /// Resolve every toast surface through the same semantic theme slots.
21 pub(crate) fn ink(self) -> codewhale_palette::ChromeInk {
22 use codewhale_palette::ChromeInk;
23 match self {
24 Self::Info => ChromeInk::Info,
25 Self::Success => ChromeInk::Outcome,
26 Self::Warning => ChromeInk::Attention,
27 Self::Error => ChromeInk::Failure,
28 }
29 }
30 }
31
32 #[derive(Debug, Clone)]
33 pub struct StatusToast {
34 pub text: String,
35 pub level: StatusToastLevel,
36 pub created_at: Instant,
37 pub ttl_ms: Option<u64>,
38 pub(crate) kind: StatusToastKind,
39 event_id: Option<String>,
40 }
41
42 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
43 pub(crate) enum StatusToastKind {
44 Ordinary,
45 ActionRequired,
46 RedactionGate(RedactionGateNotice),
47 BehavioralTip(crate::tui::behavioral_tips::BehavioralTip),
48 PluginSuggestion,
49 ContextPressure(crate::context_budget::PressureLevel),
50 /// A condition that holds until its owner retires it by event id (a
51 /// session save that keeps failing). It never times out: expiring it
52 /// would report recovery that has not happened.
53 Standing,
54 }
55
56 impl StatusToast {
57 #[must_use]
58 pub fn new(text: impl Into<String>, level: StatusToastLevel, ttl_ms: Option<u64>) -> Self {
59 Self {
60 text: text.into(),
61 level,
62 created_at: Instant::now(),
63 ttl_ms,
64 kind: StatusToastKind::Ordinary,
65 event_id: None,
66 }
67 }
68
69 pub(crate) fn for_event(mut self, event_id: impl Into<String>) -> Self {
70 self.event_id = Some(event_id.into());
71 self
72 }
73
74 /// A notice for a standing condition, withdrawn only through
75 /// [`App::retire_event_notices`]`(event_id)` once the condition clears.
76 #[must_use]
77 pub(crate) fn standing(
78 text: impl Into<String>,
79 level: StatusToastLevel,
80 event_id: impl Into<String>,
81 ) -> Self {
82 Self {
83 text: text.into(),
84 level,
85 created_at: Instant::now(),
86 ttl_ms: None,
87 kind: StatusToastKind::Standing,
88 event_id: Some(event_id.into()),
89 }
90 }
91
92 pub(crate) fn for_action(mut self, request_id: impl Into<String>) -> Self {
93 self.kind = StatusToastKind::ActionRequired;
94 self.event_id = Some(request_id.into());
95 self
96 }
97
98 pub(crate) fn for_redaction_gate(mut self, notice: RedactionGateNotice) -> Self {
99 self.kind = StatusToastKind::RedactionGate(notice);
100 self
101 }
102
103 #[must_use]
104 pub(crate) fn context_pressure(
105 text: impl Into<String>,
106 level: crate::context_budget::PressureLevel,
107 ) -> Self {
108 Self {
109 text: text.into(),
110 level: StatusToastLevel::Warning,
111 created_at: Instant::now(),
112 ttl_ms: None,
113 kind: StatusToastKind::ContextPressure(level),
114 event_id: None,
115 }
116 }
117
118 #[must_use]
119 pub fn is_expired(&self, now: Instant) -> bool {
120 self.ttl_ms.is_some_and(|ttl| {
121 now.saturating_duration_since(self.created_at).as_millis() >= u128::from(ttl)
122 })
123 }
124 }
125
126 impl App {
127 pub fn push_status_toast(
128 &mut self,
129 text: impl Into<String>,
130 level: StatusToastLevel,
131 ttl_ms: Option<u64>,
132 ) {
133 self.push_status_toast_record(StatusToast::new(text, level, ttl_ms));
134 }
135
136 /// Coalesce a still-visible duplicate without renewing its first expiry.
137 /// Decision identities keep otherwise identical requests independent.
138 pub(crate) fn push_status_toast_record(&mut self, mut toast: StatusToast) {
139 // An omitted lifetime used to leave routine notices in the queue
140 // forever, resurfacing after newer notices expired. Pending actions
141 // and safety gates have explicit kinds and keep their own lifecycle.
142 if toast.kind == StatusToastKind::Ordinary && toast.ttl_ms.is_none() {
143 toast.ttl_ms = Some(Self::STICKY_ERROR_TTL_MS);
144 }
145 self.prune_expired_status_toasts(toast.created_at);
146 if self.status_toasts.iter().any(|existing| {
147 existing.level == toast.level
148 && existing.text == toast.text
149 && existing.kind == toast.kind
150 && existing.event_id == toast.event_id
151 }) {
152 return;
153 }
154 self.status_toasts.push_back(toast);
155 // Overflow drops the oldest notice that is not a standing condition;
156 // those leave only when their condition clears.
157 while self.status_toasts.len() > 24 {
158 let oldest = self
159 .status_toasts
160 .iter()
161 .position(|toast| toast.kind != StatusToastKind::Standing)
162 .unwrap_or(0);
163 self.status_toasts.remove(oldest);
164 }
165 self.needs_redraw = true;
166 }
167
168 /// Retire requests, never their denial/error outcomes. `None` settles
169 /// requests for the whole finished/cancelled turn.
170 pub(crate) fn retire_action_notices(&mut self, request_id: Option<&str>) {
171 let before = self.status_toasts.len();
172 self.status_toasts.retain(|toast| {
173 toast.kind != StatusToastKind::ActionRequired
174 || request_id.is_some_and(|id| toast.event_id.as_deref() != Some(id))
175 });
176 self.needs_redraw |= self.status_toasts.len() != before;
177 }
178
179 /// Retire the notices pushed with [`StatusToast::for_event`]`(event_id)`.
180 pub(crate) fn retire_event_notices(&mut self, event_id: &str) {
181 let before = self.status_toasts.len();
182 self.status_toasts
183 .retain(|toast| toast.event_id.as_deref() != Some(event_id));
184 self.needs_redraw |= self.status_toasts.len() != before;
185 }
186
187 /// Gate transitions retire their own guidance or failed-write receipt,
188 /// independently of translated text and unrelated requests/errors.
189 pub(crate) fn retire_redaction_gate_notice(&mut self, notice: RedactionGateNotice) {
190 let before = self.status_toasts.len();
191 self.status_toasts
192 .retain(|toast| toast.kind != StatusToastKind::RedactionGate(notice));
193 self.needs_redraw |= self.status_toasts.len() != before;
194 }
195
196 /// Default lifetime for sticky error toasts. Long enough to read, short
197 /// enough that a failed workflow does not permanently occupy footer chrome.
198 pub const STICKY_ERROR_TTL_MS: u64 = 8_000;
199
200 pub fn set_sticky_status(
201 &mut self,
202 text: impl Into<String>,
203 level: StatusToastLevel,
204 ttl_ms: Option<u64>,
205 ) {
206 let text = text.into();
207 if self.sticky_status.as_ref().is_some_and(|existing| {
208 existing.text == text
209 && existing.level == level
210 && existing.kind == StatusToastKind::Ordinary
211 && !existing.is_expired(Instant::now())
212 }) {
213 return;
214 }
215 // Cap sticky errors so a missing TTL never becomes permanent chrome.
216 // Explicit shorter TTLs still win; longer/None fall back to the default.
217 let ttl_ms = match level {
218 StatusToastLevel::Error => Some(
219 ttl_ms
220 .unwrap_or(Self::STICKY_ERROR_TTL_MS)
221 .min(Self::STICKY_ERROR_TTL_MS),
222 ),
223 _ => ttl_ms.or(Some(Self::STICKY_ERROR_TTL_MS)),
224 };
225 self.sticky_status = Some(StatusToast::new(text, level, ttl_ms));
226 self.needs_redraw = true;
227 }
228
229 pub fn clear_sticky_status(&mut self) {
230 if self.sticky_status.take().is_some() {
231 self.needs_redraw = true;
232 }
233 }
234
235 /// Dismiss the persistent context-pressure warning without dismissing
236 /// unrelated error/status chrome. Returns whether anything was cleared.
237 pub fn dismiss_context_pressure_warning(&mut self) -> bool {
238 let is_context_pressure = self
239 .sticky_status
240 .as_ref()
241 .is_some_and(|status| matches!(status.kind, StatusToastKind::ContextPressure(_)));
242 if is_context_pressure {
243 if self.status_message.as_ref() == self.sticky_status.as_ref().map(|toast| &toast.text)
244 {
245 self.status_message = None;
246 self.last_status_message_seen = None;
247 }
248 if let Some(StatusToastKind::ContextPressure(level)) =
249 self.sticky_status.as_ref().map(|status| status.kind)
250 {
251 self.context_pressure_warning_dismissed = Some(level);
252 }
253 self.clear_sticky_status();
254 return true;
255 }
256 false
257 }
258
259 /// Drop sticky error chrome when the user resumes typing so a prior
260 /// workflow/provider failure does not linger over the next draft.
261 pub fn acknowledge_sticky_on_composer_activity(&mut self) {
262 if self
263 .sticky_status
264 .as_ref()
265 .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error))
266 {
267 self.clear_sticky_status();
268 }
269 }
270
271 pub(super) fn classify_status_text(text: &str) -> (StatusToastLevel, Option<u64>, bool) {
272 let lower = text.to_ascii_lowercase();
273 let has = |needle: &str| lower.contains(needle);
274
275 if has("offline mode") || has("context critical") {
276 return (StatusToastLevel::Warning, None, true);
277 }
278 if has("error")
279 || has("failed")
280 || has("denied")
281 || has("timeout")
282 || has("aborted")
283 || has("critical")
284 {
285 return (
286 StatusToastLevel::Error,
287 Some(Self::STICKY_ERROR_TTL_MS),
288 true,
289 );
290 }
291 // A success keyword under a negation ("not saved", "no longer
292 // found", "could not enable") is a failure the coarse keyword match
293 // would otherwise paint green. Guard it: negated success degrades to
294 // a neutral Info toast rather than a misleading Success.
295 let negated = has("not ")
296 || has("no longer")
297 || has("no ")
298 || has("could not")
299 || has("couldn't")
300 || has("cannot")
301 || has("can't")
302 || has("unable");
303 if !negated
304 && (has("saved")
305 || has("loaded")
306 || has("queued")
307 || has("found")
308 || has("enabled")
309 || has("completed"))
310 {
311 return (StatusToastLevel::Success, Some(5_000), false);
312 }
313 if has("cancelled") || has("canceled") || has("warning") {
314 return (StatusToastLevel::Warning, Some(5_000), false);
315 }
316 (StatusToastLevel::Info, Some(4_000), false)
317 }
318
319 fn is_mode_switch_status_message(message: &str) -> bool {
320 message.starts_with("Switched to ") && message.ends_with(" mode")
321 }
322
323 pub fn sync_status_message_to_toasts(&mut self) {
324 let current = self.status_message.clone();
325 if self.last_status_message_seen == current {
326 return;
327 }
328 self.last_status_message_seen = current.clone();
329
330 let Some(message) = current else {
331 return;
332 };
333 if message.trim().is_empty() {
334 return;
335 }
336 if Self::is_mode_switch_status_message(&message) {
337 return;
338 }
339 let now = Instant::now();
340 // A typed producer already owns this notice. The legacy adapter must
341 // not reclassify tool text or create a second sticky/queued copy.
342 if self
343 .status_toasts
344 .iter()
345 .chain(self.sticky_status.iter())
346 .any(|toast| toast.text == message && !toast.is_expired(now))
347 {
348 return;
349 }
350
351 let (level, ttl_ms, sticky) = Self::classify_status_text(&message);
352 if sticky {
353 self.set_sticky_status(message, level, ttl_ms);
354 } else {
355 // A routine success ("Auto-compaction enabled") must not clear
356 // the one error that says no model is connected: nothing but
357 // connecting a provider resolves it (U1).
358 if matches!(level, StatusToastLevel::Success)
359 && !self.onboarding_needs_api_key
360 && self
361 .sticky_status
362 .as_ref()
363 .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error))
364 {
365 self.clear_sticky_status();
366 }
367 self.push_status_toast(message, level, ttl_ms);
368 }
369 }
370
371 fn prune_expired_status_toasts(&mut self, now: Instant) {
372 let queued_before = self.status_toasts.len();
373 self.status_toasts.retain(|toast| !toast.is_expired(now));
374 let queued_removed = self.status_toasts.len() != queued_before;
375 let sticky_removed = self
376 .sticky_status
377 .as_ref()
378 .is_some_and(|toast| toast.is_expired(now));
379 if sticky_removed {
380 self.sticky_status = None;
381 }
382 if queued_removed || sticky_removed {
383 self.needs_redraw = true;
384 }
385 }
386
387 pub fn active_status_toast(
388 &mut self,
389 phase: crate::tui::underwater::ShellPhase,
390 ) -> Option<StatusToast> {
391 self.sync_status_message_to_toasts();
392 let now = Instant::now();
393 self.prune_expired_status_toasts(now);
394
395 let eligible = |toast: &&StatusToast| {
396 phase != crate::tui::underwater::ShellPhase::Done
397 || matches!(
398 toast.level,
399 StatusToastLevel::Warning | StatusToastLevel::Error
400 )
401 };
402 let sticky = self.sticky_status.as_ref().filter(eligible).cloned();
403 let latest = self.status_toasts.iter().rev().find(eligible).cloned();
404 match (sticky, latest) {
405 (Some(sticky), Some(latest))
406 if matches!(
407 sticky.kind,
408 StatusToastKind::ContextPressure(crate::context_budget::PressureLevel::High)
409 | StatusToastKind::ContextPressure(
410 crate::context_budget::PressureLevel::Medium
411 )
412 ) =>
413 {
414 Some(latest)
415 }
416 (Some(sticky), _) => Some(sticky),
417 (None, latest) => latest,
418 }
419 }
420 }
421
422 #[cfg(test)]
423 mod tests {
424 use super::*;
425 use std::time::Duration;
426
427 fn app() -> App {
428 App::new(
429 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
430 &crate::config::Config::default(),
431 )
432 }
433
434 #[test]
435 fn live_toast_duplicates_keep_first_expiry_and_distinct_decisions() {
436 let mut app = app();
437 let now = Instant::now();
438 let record = |id: &str, at: Instant| {
439 let mut toast = StatusToast::new(
440 "Review this request",
441 StatusToastLevel::Warning,
442 Some(2_000),
443 )
444 .for_action(id);
445 toast.created_at = at;
446 toast
447 };
448 app.push_status_toast_record(record("a", now));
449 app.push_status_toast_record(record("a", now + Duration::from_millis(1_000)));
450 assert_eq!(app.status_toasts.len(), 1);
451 assert_eq!(app.status_toasts[0].created_at, now);
452 app.push_status_toast_record(record("b", now + Duration::from_millis(1_000)));
453 assert_eq!(app.status_toasts.len(), 2);
454 app.push_status_toast_record(record("a", now + Duration::from_millis(2_000)));
455 assert_eq!(
456 app.status_toasts.len(),
457 2,
458 "expired a is replaced; independent b survives"
459 );
460 assert_eq!(
461 app.status_toasts.back().unwrap().created_at,
462 now + Duration::from_millis(2_000)
463 );
464 }
465
466 #[test]
467 fn routine_notices_expire_without_dismissing_pending_actions_or_safety_gates() {
468 let mut app = app();
469 let now = Instant::now();
470 app.push_status_toast("Temporary warning", StatusToastLevel::Warning, None);
471 app.push_status_toast("Copied", StatusToastLevel::Info, None);
472 app.set_sticky_status("Offline mode", StatusToastLevel::Warning, None);
473 app.push_status_toast_record(
474 StatusToast::new("Review request", StatusToastLevel::Warning, None)
475 .for_action("pending"),
476 );
477 app.push_status_toast_record(
478 StatusToast::new("Review redaction", StatusToastLevel::Warning, None)
479 .for_redaction_gate(RedactionGateNotice::WriteFailure),
480 );
481 app.prune_expired_status_toasts(now + Duration::from_secs(10));
482 assert!(app.sticky_status.is_none());
483 assert_eq!(app.status_toasts.len(), 2);
484 assert_eq!(app.status_toasts[0].kind, StatusToastKind::ActionRequired);
485 assert_eq!(
486 app.status_toasts[1].kind,
487 StatusToastKind::RedactionGate(RedactionGateNotice::WriteFailure)
488 );
489 }
490
491 #[test]
492 fn legacy_status_does_not_reclassify_a_typed_notice_or_repeat_a_live_notice() {
493 let mut app = app();
494 let text = "承認してください · failed-tool";
495 app.push_status_toast(text, StatusToastLevel::Warning, Some(12_000));
496 let first = app.status_toasts[0].created_at;
497 for message in [text, "another status", text] {
498 app.status_message = Some(message.into());
499 app.sync_status_message_to_toasts();
500 }
501 assert_eq!(app.status_toasts.len(), 2);
502 assert_eq!(app.status_toasts[0].level, StatusToastLevel::Warning);
503 assert_eq!(app.status_toasts[0].created_at, first);
504 assert!(
505 app.sticky_status.is_none(),
506 "tool data must not create an inferred error"
507 );
508 }
509
510 #[test]
511 fn repeated_sticky_error_does_not_renew_its_expiry() {
512 let mut app = app();
513 app.set_sticky_status("same failure", StatusToastLevel::Error, None);
514 let created_at = app.sticky_status.as_ref().unwrap().created_at;
515 app.set_sticky_status("same failure", StatusToastLevel::Error, None);
516 assert_eq!(app.sticky_status.as_ref().unwrap().created_at, created_at);
517 app.sticky_status.as_mut().unwrap().created_at = created_at - Duration::from_secs(10);
518 app.set_sticky_status("same failure", StatusToastLevel::Error, None);
519 assert!(app.sticky_status.as_ref().unwrap().created_at >= created_at);
520 }
521
522 #[test]
523 fn retiring_action_notices_preserves_other_requests_and_outcome_receipts() {
524 let mut app = app();
525 for id in ["a", "b"] {
526 app.push_status_toast_record(
527 StatusToast::new("Review", StatusToastLevel::Warning, None).for_action(id),
528 );
529 }
530 app.push_status_toast_record(
531 StatusToast::new("Denied", StatusToastLevel::Warning, None).for_event("a"),
532 );
533 app.retire_action_notices(Some("a"));
534 assert_eq!(app.status_toasts.len(), 2);
535 assert_eq!(app.status_toasts[0].event_id.as_deref(), Some("b"));
536 app.retire_action_notices(None);
537 assert_eq!(app.status_toasts.len(), 1);
538 assert_eq!(app.status_toasts[0].text, "Denied");
539 }
540
541 #[test]
542 fn redaction_gate_cleanup_preserves_failed_writes_until_settled_and_unrelated_notices() {
543 let mut app = app();
544 // Deliberately equal translated text: identity must own cleanup.
545 for notice in [
546 RedactionGateNotice::EnterGuidance,
547 RedactionGateNotice::WriteFailure,
548 ] {
549 app.push_status_toast_record(
550 StatusToast::new("確認してください", StatusToastLevel::Warning, None)
551 .for_redaction_gate(notice),
552 );
553 }
554 app.push_status_toast("確認してください", StatusToastLevel::Warning, None);
555 app.push_status_toast_record(
556 StatusToast::new("Review", StatusToastLevel::Warning, None).for_action("request"),
557 );
558 app.set_sticky_status("Unrelated failure", StatusToastLevel::Error, None);
559 app.needs_redraw = false;
560 app.retire_redaction_gate_notice(RedactionGateNotice::EnterGuidance);
561 assert!(app.needs_redraw);
562 assert_eq!(app.status_toasts.len(), 3);
563 assert_eq!(
564 app.status_toasts[0].kind,
565 StatusToastKind::RedactionGate(RedactionGateNotice::WriteFailure)
566 );
567 app.retire_redaction_gate_notice(RedactionGateNotice::WriteFailure);
568 assert_eq!(app.status_toasts.len(), 2);
569 assert_eq!(app.status_toasts[0].kind, StatusToastKind::Ordinary);
570 assert_eq!(app.status_toasts[1].kind, StatusToastKind::ActionRequired);
571 assert_eq!(
572 app.sticky_status.as_ref().unwrap().text,
573 "Unrelated failure"
574 );
575 }
576 }
577
577 lines RUST