fix: route warnings to file and add in-app toast notifications

- Replace all eprintln! with tracing::warn! to avoid TUI corruption
  via stderr writes during alternate screen mode
- Route tracing output to ~/.local/share/zesdex/zesdex.log instead of
  stderr by configuring tracing_subscriber with a Mutex<File> writer
- Add render_toasts() widget: floating notification stack at top-right
  of the terminal, color-coded by severity (Info/Success/Warning/Error/
  Lesson), auto-expires after 5s, max 4 visible
- Apply to 12 files: stream parser, MCP client, subagent engine,
  state/rest, controller/input, app_config, provider, OAuth, agent_def,
  dto/chat/tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-12 10:57:32 +07:00
co-authored by Claude Opus 4.8
parent e29dfadaa7
commit 89b63b1bc2
13 changed files with 92 additions and 30 deletions
+46
View File
@@ -36,6 +36,9 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
render_input_bar(frame, input_area, state);
status::draw_status_bar(frame, status_area, state);
// Toast notifications at top-right (like Hyprland)
render_toasts(frame, state);
}
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
@@ -564,6 +567,49 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re
frame.render_widget(paragraph, area);
}
/// Render active toasts as a floating stack at top-right of the terminal.
/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once.
fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter()
.filter(|t| !t.expired(now_ms))
.collect();
if active.is_empty() {
return;
}
let area = frame.area();
let toast_w: u16 = 45;
let x = area.width.saturating_sub(toast_w).saturating_sub(2);
let mut y: u16 = 1;
for toast in active.iter().rev().take(4) {
let line_count = toast.message.lines().count().max(1) as u16;
let h = line_count + 2; // border top + border bottom
let toast_area = Rect { x, y, width: toast_w, height: h };
if toast_area.bottom() > area.height {
break;
}
frame.render_widget(ratatui::widgets::Clear, toast_area);
let border_color = match toast.kind {
crate::app::state::types::ToastKind::Success => Theme::SUCCESS,
crate::app::state::types::ToastKind::Warning => Theme::WARNING,
crate::app::state::types::ToastKind::Error => Theme::ERROR,
crate::app::state::types::ToastKind::Info => Theme::INFO,
crate::app::state::types::ToastKind::Lesson => Theme::PRIMARY,
};
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(ratatui::style::Style::default().fg(border_color))
.style(ratatui::style::Style::default().bg(ratatui::style::Color::Black));
let paragraph = ratatui::widgets::Paragraph::new(toast.message.as_str())
.block(block)
.wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(paragraph, toast_area);
y = y.saturating_add(h).saturating_add(1);
}
}
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2;
let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2;