1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! Toasts (transient, stack, persistent), progress bars, dynamic
//! statusline segments, and OS-level notifications (OSC 9 / 777).
//! This is the SDK surface integrations write to via `mnml-bridge`.
//!
//! Extracted from `app/mod.rs` (file-split refactor — Task #963).
//! Pure non-destructive move; no API change.
use super::*;
impl App {
/// Level-tagged toast. Info + warn render with the standard
/// comment-color border; error renders red. All toasts also
/// land in `message_log` (recoverable via `:messages`).
pub fn toast_leveled(&mut self, msg: impl Into<String>, level: ToastLevel) {
let s: String = msg.into();
// `:silent <cmd>` suppresses the visible toast but the message
// is still recorded in the log so `:messages` can recover it.
if self.silent_depth == 0 {
let now = Instant::now();
self.toast = Some((s.clone(), now));
let entry = ToastEntry {
text: s.clone(),
created_at: now,
level,
persistent_id: None,
};
self.toast_stack.push_front(entry);
while self.toast_stack.len() > TOAST_STACK_MAX {
self.toast_stack.pop_back();
}
}
self.message_log.push(s);
if self.message_log.len() > MESSAGE_LOG_MAX {
let drop = self.message_log.len() - MESSAGE_LOG_MAX;
self.message_log.drain(..drop);
}
}
/// Convenience — level=Error (renders with red border).
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.toast_leveled(msg, ToastLevel::Error);
}
/// Show a pinned toast identified by `id`. A repeat call with
/// the same `id` updates the text/level in place (single toast,
/// not stacked). Stays visible until `toast_dismiss(id)`.
pub fn toast_persistent(
&mut self,
id: impl Into<String>,
msg: impl Into<String>,
level: ToastLevel,
) {
let id: String = id.into();
let s: String = msg.into();
self.message_log.push(s.clone());
if self.message_log.len() > MESSAGE_LOG_MAX {
let drop = self.message_log.len() - MESSAGE_LOG_MAX;
self.message_log.drain(..drop);
}
if self.silent_depth > 0 {
return;
}
if let Some(slot) = self
.persistent_toasts
.iter_mut()
.find(|t| t.persistent_id.as_deref() == Some(id.as_str()))
{
slot.text = s;
slot.level = level;
slot.created_at = Instant::now();
} else {
self.persistent_toasts.push(ToastEntry {
text: s,
created_at: Instant::now(),
level,
persistent_id: Some(id),
});
}
}
/// Dismiss a persistent toast by id. No-op if the id isn't
/// currently pinned.
pub fn toast_dismiss(&mut self, id: &str) {
self.persistent_toasts
.retain(|t| t.persistent_id.as_deref() != Some(id));
}
/// Start (or restart) a progress notification for `id`. If an
/// item with the same id already exists, it's reset —
/// spinner phase restarts, percent clears.
pub fn progress_start(&mut self, id: impl Into<String>, label: impl Into<String>) {
let id: String = id.into();
let label: String = label.into();
if self.silent_depth > 0 {
return;
}
if let Some(slot) = self.progress_items.iter_mut().find(|p| p.id == id) {
slot.label = label;
slot.percent = None;
slot.started_at = Instant::now();
slot.finished = None;
} else {
self.progress_items.push(ProgressItem {
id,
label,
percent: None,
started_at: Instant::now(),
finished: None,
});
}
}
/// Update an in-flight progress item. `label` is optional —
/// pass `None` to keep the previous label. `percent` similarly
/// optional; clamped to 0..=100. No-op if `id` isn't tracked
/// or already finished.
pub fn progress_update(&mut self, id: &str, label: Option<String>, percent: Option<u8>) {
if let Some(p) = self.progress_items.iter_mut().find(|p| p.id == id)
&& p.finished.is_none()
{
if let Some(l) = label {
p.label = l;
}
if let Some(pct) = percent {
p.percent = Some(pct.min(100));
}
}
}
/// Finish a progress item. Sets its terminal status glyph and
/// starts the fade timer — the row lingers for
/// [`PROGRESS_END_FADE`] before removal so the user can see
/// the outcome. `Failed` also fires a `toast_error` with the
/// item's label. `Success` and `Cancelled` don't toast (the
/// on-screen glyph is enough — cheap common cases).
pub fn progress_end(&mut self, id: &str, status: ProgressStatus) {
let Some(p) = self.progress_items.iter_mut().find(|p| p.id == id) else {
return;
};
if p.finished.is_some() {
return;
}
p.finished = Some((status, Instant::now()));
let label = p.label.clone();
if matches!(status, ProgressStatus::Failed) {
self.toast_error(format!("failed: {label}"));
}
}
/// Purge progress items whose fade has elapsed. Called from
/// the main tick.
pub(crate) fn expire_progress_items(&mut self) {
self.progress_items.retain(|p| match p.finished {
None => true,
Some((_, at)) => at.elapsed() < PROGRESS_END_FADE,
});
}
/// Insert or update a integration statusline segment. Keyed by
/// `id`; repeat calls with the same id update the entry in
/// place. Rendered on the next paint.
#[allow(clippy::too_many_arguments)]
pub fn statusline_set_segment(
&mut self,
id: impl Into<String>,
side: SegmentSide,
text: impl Into<String>,
color: Option<String>,
click_command: Option<String>,
priority: u8,
min_width: u16,
max_width: u16,
) {
// Delegate to the tooltip-aware setter with tooltip=None so
// existing integration IPC callers stay call-compatible while
// the manifest-driven poll pipeline can pass a real tooltip.
self.statusline_set_segment_full(
id,
side,
text,
color,
None,
click_command,
priority,
min_width,
max_width,
);
}
/// Task #965 follow-up 2026-08-17 — same as `statusline_set_segment`
/// with an added `tooltip` slot so the manifest-driven pipeline
/// can surface `waiting for first poll` / `last error: …` states
/// through hover-help instead of silently swallowing them.
#[allow(clippy::too_many_arguments)]
pub fn statusline_set_segment_full(
&mut self,
id: impl Into<String>,
side: SegmentSide,
text: impl Into<String>,
color: Option<String>,
tooltip: Option<String>,
click_command: Option<String>,
priority: u8,
min_width: u16,
max_width: u16,
) {
let id: String = id.into();
let text: String = text.into();
if let Some(slot) = self.dynamic_segments.iter_mut().find(|s| s.id == id) {
slot.side = side;
slot.text = text;
slot.color = color;
slot.tooltip = tooltip;
slot.click_command = click_command;
slot.priority = priority;
slot.min_width = min_width;
slot.max_width = max_width;
slot.last_updated = Instant::now();
} else {
self.dynamic_segments.push(DynamicSegment {
id,
side,
text,
color,
tooltip,
click_command,
priority,
min_width,
max_width,
last_updated: Instant::now(),
});
}
}
/// Remove a integration statusline segment by id.
pub fn statusline_clear_segment(&mut self, id: &str) {
self.dynamic_segments.retain(|s| s.id != id);
}
/// Fire a notification. Always renders an in-app toast at
/// `level` (per Call 1: info + warn share the comment border,
/// error gets red). If the `source` integration's manifest
/// permits OS notifications and the per-integration rate
/// limit has elapsed, also queues the OSC 9 / OSC 777 escape
/// sequences for the next render pass — the terminal (Ghostty
/// / iTerm2 / kitty) routes those to native banners.
///
/// Rate-limit behavior:
/// - `source = None` → always fires (no rate tracking).
/// - `source = Some(id)` → suppressed if last fire was
/// within `os_rate_limit_sec` on the integration's
/// manifest (default 5s). Suppressed OS fires still fire
/// the in-app toast.
pub fn notify(
&mut self,
title: impl Into<String>,
body: impl Into<String>,
level: ToastLevel,
sound: bool,
source: Option<&str>,
) {
let title: String = title.into();
let body: String = body.into();
// In-app toast — `level=error` pins to the persistent slot
// so critical notifications don't expire on TTL. `warn`
// and `info` stay ephemeral like before. mouse-round-10
// SEV-3 2026-07-12.
if matches!(level, ToastLevel::Error) {
// Use the source id when provided so a follow-up
// notify(same source) updates the existing pinned
// slot in place rather than stacking a duplicate. Fall
// back to a title-derived id so at least the same title
// deduplicates.
let id = source
.map(str::to_string)
.unwrap_or_else(|| format!("notify:{title}"));
self.toast_persistent(id, format!("{title}: {body}"), level);
} else {
self.toast_leveled(format!("{title}: {body}"), level);
}
// OS notification is opt-in per integration.
let (os_ok, rate_secs) = match source {
None => (true, 0), // no source → no policy → fire
Some(id) => self.os_notify_policy_for(id),
};
if !os_ok {
return;
}
if let Some(src) = source
&& rate_secs > 0
{
let now = Instant::now();
if let Some(&last) = self.notify_last_fired.get(src)
&& now.duration_since(last) < Duration::from_secs(rate_secs)
{
return; // rate-limited — in-app toast only
}
self.notify_last_fired.insert(src.to_string(), now);
}
self.pending_os_notifications.push((title, body, sound));
}
/// Resolve the OS-notification policy for an integration id
/// by consulting its manifest. Returns `(should_fire,
/// rate_secs)`. Absent manifest → default policy: fire, no
/// rate limit. Present manifest with `os_notify_on = "never"`
/// → don't fire.
fn os_notify_policy_for(&self, id: &str) -> (bool, u64) {
let Some(m) = self.integration_manifests.iter().find(|m| m.id == id) else {
return (true, 0);
};
let Some(n) = &m.notifications else {
return (true, 0);
};
let fire = !matches!(
n.os_notify_on,
crate::integration_manifest::OsNotifyPolicy::Never
);
(fire, n.os_rate_limit_sec)
}
/// Drain queued OS notifications — invoked by the tui render
/// loop after `term.draw`. Returns the drained items so the
/// caller can flush them via crossterm's `execute!` (App
/// doesn't own stdout).
pub fn take_pending_os_notifications(&mut self) -> Vec<(String, String, bool)> {
std::mem::take(&mut self.pending_os_notifications)
}
/// Current toast text if it hasn't expired AND there's no live
/// top-right toast box painting the same message. mouse-round-11
/// SEV-3 2026-07-12 — solo toasts now always render as a box
/// (round-10 SEV-3 fix), so echoing the same string on the
/// cmdline row while the box is up double-renders. Cmdline
/// echo returns only once every box has faded (log-tail
/// semantic) so the message still persists past the box's TTL.
pub fn live_toast(&self) -> Option<&str> {
if !self.toast_stack.is_empty() || !self.persistent_toasts.is_empty() {
return None;
}
self.toast
.as_ref()
.filter(|(_, t)| t.elapsed() < TOAST_TTL)
.map(|(s, _)| s.as_str())
}
}