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