use std::time::{Duration, Instant};
use ratatui::{
layout::{Alignment, Rect},
style::Style,
widgets::{Clear, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
use super::{render::truncate_one_line, theme::Theme};
const STATUS_OVERLAY_DURATION: Duration = Duration::from_secs(2);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum StatusTone {
Success,
Warning,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct StatusOverlay {
message: String,
tone: StatusTone,
visible_until: Instant,
}
impl StatusOverlay {
pub(super) fn new(message: impl Into<String>, tone: StatusTone, now: Instant) -> Self {
Self {
message: message.into(),
tone,
visible_until: now + STATUS_OVERLAY_DURATION,
}
}
pub(super) fn message(&self) -> &str {
&self.message
}
pub(super) fn tone(&self) -> StatusTone {
self.tone
}
pub(super) fn visible_until(&self) -> Instant {
self.visible_until
}
pub(super) fn is_visible(&self, now: Instant) -> bool {
now < self.visible_until
}
}
pub(super) fn should_toast(message: &str) -> bool {
!is_routine_status(message)
}
fn is_routine_status(message: &str) -> bool {
let message = message.trim();
if message.is_empty() {
return true;
}
if matches!(
message,
"ready"
| "running"
| "config"
| "skills"
| "workflows"
| "error"
| "approval requested"
| "compacting context"
| "retrying provider response"
| "evaluating goal"
| "goal retrying"
| "loading models"
| "refreshing model list"
| "checking OAuth usage limits"
| "checking provider connections"
| "fetching latest changelog"
| "waiting for delegated agents"
| "waiting for approval"
| "waiting for your answers"
| "keyboard shortcuts"
| "runtime info"
| "doctor diagnostics"
| "web search config"
| "changelog usage"
) {
return true;
}
let lower = message.to_ascii_lowercase();
if lower.starts_with("running step ")
|| lower.starts_with("running ")
|| lower.starts_with("select ")
|| lower.starts_with("edit ")
|| lower.starts_with("confirm ")
|| lower.starts_with("choose ")
|| lower.starts_with("extracting ")
|| lower.starts_with("checking ")
|| lower.starts_with("fetching ")
|| lower.starts_with("loading ")
|| lower.starts_with("refreshing ")
|| lower.starts_with("waiting for ")
|| lower.starts_with("starting ")
|| lower.starts_with("switch ")
|| lower.starts_with("opening a herdr pane ")
{
return true;
}
matches!(
lower.as_str(),
"config · saves automatically"
| "conversation tree"
| "doctor diagnostics"
| "inline shell"
| "keyboard shortcuts"
| "loaded agents"
| "loaded skills"
| "permission mode"
| "refresh model lists"
| "resume session"
| "web search config"
| "workspace rewind"
| "workflows"
)
}
pub(super) fn tone_for_message(message: &str) -> StatusTone {
let message = message.to_ascii_lowercase();
if contains_any(
&message,
&[
"fail",
"error",
"rejected",
"unavailable",
"invalid",
"conflict",
"could not",
"cannot",
"incomplete",
],
) {
return StatusTone::Error;
}
if contains_any(
&message,
&[
"saved",
"complete",
"attached",
"loaded",
"cleared",
"done",
"inserted",
"updated",
"renamed",
"exported",
"deleted",
"success",
"unchanged",
],
) {
return StatusTone::Success;
}
StatusTone::Warning
}
fn contains_any(message: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| message.contains(needle))
}
fn tone_style(tone: StatusTone) -> Style {
match tone {
StatusTone::Success => Theme::success(),
StatusTone::Warning => Theme::warning(),
StatusTone::Error => Theme::error(),
}
}
pub(super) fn render_status_overlay(
frame: &mut Frame<'_>,
area: Rect,
overlay: &StatusOverlay,
now: Instant,
top_offset: u16,
) {
if !overlay.is_visible(now) || area.width == 0 || area.height == 0 {
return;
}
if top_offset >= area.height {
return;
}
let max_width = area.width as usize;
let text = truncate_one_line(overlay.message(), max_width.saturating_sub(2).max(1));
let popup_width = UnicodeWidthStr::width(text.as_str())
.saturating_add(2)
.min(max_width)
.max(1) as u16;
let popup = Rect::new(
area.x
.saturating_add(area.width.saturating_sub(popup_width)),
area.y.saturating_add(top_offset),
popup_width,
1,
);
frame.render_widget(Clear, popup);
frame.render_widget(
Paragraph::new(format!(" {text} "))
.alignment(Alignment::Right)
.style(tone_style(overlay.tone())),
popup,
);
}
#[cfg(test)]
#[path = "status_overlay_tests.rs"]
mod tests;