feat(tui): stacked toast overlay above footer (#439)
The status-toast bus already typed Info/Success/Warning/Error with configurable per-toast TTL, a 24-bounded queue, and a sync adapter that migrates legacy `app.status_message` writes — what was missing was visibility when several events arrive in quick succession. The footer showed only the most recent and the rest expired silently. * New `App::active_status_toasts(limit)` returns up to `limit` currently active toasts (sticky pinned first, then queued newest-last so a stack reads chronologically). Drains expired toasts off the front as a side effect — same cleanup as the single-toast path. * New `render_toast_stack_overlay` renders up to 2 *additional* toasts as a 1-2 line strip directly above the footer when the queue has 2+ entries. Doesn't touch the layout chunk constraints — it's an absolute-position overlay, so the chat area never reflows when toasts arrive or expire. Older entries render dimmed in the level color so the freshest still draws the eye in the footer line itself. * `TOAST_STACK_MAX_VISIBLE = 3` (footer line + up to 2 overlay rows). Anything beyond that ages out silently as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<StatusToast> {
|
||||
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<StatusToast> = 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<StatusToast> = 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<StatusToast> {
|
||||
self.sync_status_message_to_toasts();
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user