diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 18130bca..f1d274b8 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2101,6 +2101,51 @@ impl App { } } + /// Up to `limit` currently-active toasts, most recent last (so a stacked + /// renderer iterating top-to-bottom shows the freshest message at the + /// bottom, like a chat log). Drains expired toasts off the front as a + /// side effect — same cleanup as `active_status_toast` so callers see a + /// consistent queue. Whalescale#439. + pub fn active_status_toasts(&mut self, limit: usize) -> Vec { + self.sync_status_message_to_toasts(); + let now = Instant::now(); + while self + .status_toasts + .front() + .is_some_and(|toast| toast.is_expired(now)) + { + self.status_toasts.pop_front(); + self.needs_redraw = true; + } + if self + .sticky_status + .as_ref() + .is_some_and(|toast| toast.is_expired(now)) + { + self.sticky_status = None; + self.needs_redraw = true; + } + + let mut out: Vec = Vec::with_capacity(limit); + if let Some(sticky) = self.sticky_status.clone() { + out.push(sticky); + } + let take = limit.saturating_sub(out.len()); + let queued: Vec = self + .status_toasts + .iter() + .rev() + .take(take) + .cloned() + .collect(); + // Iterate in queue order (oldest of the visible window first) so the + // stacked renderer feels chronological — most recent at the bottom. + for toast in queued.into_iter().rev() { + out.push(toast); + } + out + } + pub fn active_status_toast(&mut self) -> Option { self.sync_status_message_to_toasts(); let now = Instant::now(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index aa24d736..f16aa259 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -4665,6 +4665,10 @@ fn render(f: &mut Frame, app: &mut App) { // Render footer render_footer(f, chunks[4], app); + // Toast stack overlay (#439): when multiple status toasts are queued, + // surface the older ones as a 1-2 line strip above the footer so a + // burst of events isn't collapsed to a single visible message. + render_toast_stack_overlay(f, size, chunks[4], app); if !app.view_stack.is_empty() { // The live transcript overlay snapshots the app's history + active @@ -5465,6 +5469,54 @@ fn status_color(level: StatusToastLevel) -> ratatui::style::Color { } } +/// Maximum stacked toasts rendered above the footer (#439). The footer line +/// itself stays the most-recent; this overlay surfaces up to two older +/// queued toasts so a burst of status events isn't dropped silently. +const TOAST_STACK_MAX_VISIBLE: usize = 3; + +/// Render up to `TOAST_STACK_MAX_VISIBLE - 1` *additional* toasts as an +/// overlay just above the footer when multiple are active. The most recent +/// toast continues to render in the footer line itself; this strip is for +/// the older entries the user would otherwise miss when statuses arrive in +/// bursts. +fn render_toast_stack_overlay(f: &mut Frame, full_area: Rect, footer_area: Rect, app: &mut App) { + let toasts = app.active_status_toasts(TOAST_STACK_MAX_VISIBLE); + if toasts.len() < 2 || footer_area.y == 0 { + return; + } + // Drop the most recent (rendered inline by the footer), keep the rest. + let extra = toasts.len() - 1; + let stack_height = extra.min(TOAST_STACK_MAX_VISIBLE - 1) as u16; + let max_above = footer_area.y.min(full_area.height); + if stack_height == 0 || max_above == 0 { + return; + } + let height = stack_height.min(max_above); + let stack_area = Rect { + x: full_area.x, + y: footer_area.y.saturating_sub(height), + width: full_area.width, + height, + }; + // Iterate oldest-first so the freshest *non-inline* toast is closest to + // the footer (visually nearest the most-recent message in the line below). + let visible = &toasts[..extra]; + for (i, toast) in visible.iter().take(height as usize).enumerate() { + let row_y = stack_area.y + i as u16; + let row = Rect { + x: stack_area.x, + y: row_y, + width: stack_area.width, + height: 1, + }; + let style = ratatui::style::Style::default() + .fg(status_color(toast.level)) + .add_modifier(ratatui::style::Modifier::DIM); + let line = ratatui::text::Line::styled(format!(" {} ", toast.text), style); + f.render_widget(ratatui::widgets::Paragraph::new(line), row); + } +} + fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { if area.width == 0 || area.height == 0 { return;