返回 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 #[derive(Debug, Clone)]
20 pub struct StatusToast {
21 pub text: String,
22 pub level: StatusToastLevel,
23 pub created_at: Instant,
24 pub ttl_ms: Option<u64>,
25 }
26
27 impl StatusToast {
28 #[must_use]
29 pub fn new(text: impl Into<String>, level: StatusToastLevel, ttl_ms: Option<u64>) -> Self {
30 Self {
31 text: text.into(),
32 level,
33 created_at: Instant::now(),
34 ttl_ms,
35 }
36 }
37
38 #[must_use]
39 pub fn is_expired(&self, now: Instant) -> bool {
40 self.ttl_ms
41 .is_some_and(|ttl| now.duration_since(self.created_at).as_millis() >= u128::from(ttl))
42 }
43 }
44
45 impl App {
46 pub fn push_status_toast(
47 &mut self,
48 text: impl Into<String>,
49 level: StatusToastLevel,
50 ttl_ms: Option<u64>,
51 ) {
52 let toast = StatusToast::new(text, level, ttl_ms);
53 self.status_toasts.push_back(toast);
54 while self.status_toasts.len() > 24 {
55 self.status_toasts.pop_front();
56 }
57 self.needs_redraw = true;
58 }
59
60 /// Default lifetime for sticky error toasts. Long enough to read, short
61 /// enough that a failed workflow does not permanently occupy footer chrome.
62 pub const STICKY_ERROR_TTL_MS: u64 = 8_000;
63
64 pub fn set_sticky_status(
65 &mut self,
66 text: impl Into<String>,
67 level: StatusToastLevel,
68 ttl_ms: Option<u64>,
69 ) {
70 // Cap sticky errors so a missing TTL never becomes permanent chrome.
71 // Explicit shorter TTLs still win; longer/None fall back to the default.
72 let ttl_ms = match level {
73 StatusToastLevel::Error => Some(
74 ttl_ms
75 .unwrap_or(Self::STICKY_ERROR_TTL_MS)
76 .min(Self::STICKY_ERROR_TTL_MS),
77 ),
78 _ => ttl_ms,
79 };
80 self.sticky_status = Some(StatusToast::new(text, level, ttl_ms));
81 self.needs_redraw = true;
82 }
83
84 pub fn clear_sticky_status(&mut self) {
85 if self.sticky_status.take().is_some() {
86 self.needs_redraw = true;
87 }
88 }
89
90 /// Drop sticky error chrome when the user resumes typing so a prior
91 /// workflow/provider failure does not linger over the next draft.
92 pub fn acknowledge_sticky_on_composer_activity(&mut self) {
93 if self
94 .sticky_status
95 .as_ref()
96 .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error))
97 {
98 self.clear_sticky_status();
99 }
100 }
101
102 pub(super) fn classify_status_text(text: &str) -> (StatusToastLevel, Option<u64>, bool) {
103 let lower = text.to_ascii_lowercase();
104 let has = |needle: &str| lower.contains(needle);
105
106 if has("offline mode") || has("context critical") {
107 return (StatusToastLevel::Warning, None, true);
108 }
109 if has("error")
110 || has("failed")
111 || has("denied")
112 || has("timeout")
113 || has("aborted")
114 || has("critical")
115 {
116 return (
117 StatusToastLevel::Error,
118 Some(Self::STICKY_ERROR_TTL_MS),
119 true,
120 );
121 }
122 // A success keyword under a negation ("not saved", "no longer
123 // found", "could not enable") is a failure the coarse keyword match
124 // would otherwise paint green. Guard it: negated success degrades to
125 // a neutral Info toast rather than a misleading Success.
126 let negated = has("not ")
127 || has("no longer")
128 || has("no ")
129 || has("could not")
130 || has("couldn't")
131 || has("cannot")
132 || has("can't")
133 || has("unable");
134 if !negated
135 && (has("saved")
136 || has("loaded")
137 || has("queued")
138 || has("found")
139 || has("enabled")
140 || has("completed"))
141 {
142 return (StatusToastLevel::Success, Some(5_000), false);
143 }
144 if has("cancelled") || has("canceled") || has("warning") {
145 return (StatusToastLevel::Warning, Some(5_000), false);
146 }
147 (StatusToastLevel::Info, Some(4_000), false)
148 }
149
150 fn is_mode_switch_status_message(message: &str) -> bool {
151 message.starts_with("Switched to ") && message.ends_with(" mode")
152 }
153
154 pub fn sync_status_message_to_toasts(&mut self) {
155 let current = self.status_message.clone();
156 if self.last_status_message_seen == current {
157 return;
158 }
159 self.last_status_message_seen = current.clone();
160
161 let Some(message) = current else {
162 return;
163 };
164 if message.trim().is_empty() {
165 return;
166 }
167 if Self::is_mode_switch_status_message(&message) {
168 return;
169 }
170
171 let (level, ttl_ms, sticky) = Self::classify_status_text(&message);
172 if sticky {
173 self.set_sticky_status(message, level, ttl_ms);
174 } else {
175 if matches!(level, StatusToastLevel::Success)
176 && self
177 .sticky_status
178 .as_ref()
179 .is_some_and(|toast| matches!(toast.level, StatusToastLevel::Error))
180 {
181 self.clear_sticky_status();
182 }
183 self.push_status_toast(message, level, ttl_ms);
184 }
185 }
186
187 fn prune_expired_status_toasts(&mut self, now: Instant) {
188 let queued_before = self.status_toasts.len();
189 self.status_toasts.retain(|toast| !toast.is_expired(now));
190 let queued_removed = self.status_toasts.len() != queued_before;
191 let sticky_removed = self
192 .sticky_status
193 .as_ref()
194 .is_some_and(|toast| toast.is_expired(now));
195 if sticky_removed {
196 self.sticky_status = None;
197 }
198 if queued_removed || sticky_removed {
199 self.needs_redraw = true;
200 }
201 }
202
203 pub fn active_status_toast(&mut self) -> Option<StatusToast> {
204 self.sync_status_message_to_toasts();
205 let now = Instant::now();
206 self.prune_expired_status_toasts(now);
207
208 self.sticky_status
209 .clone()
210 .or_else(|| self.status_toasts.back().cloned())
211 }
212 }
213
213 lines RUST