diff --git a/crates/miyabi-tui/src/notification.rs b/crates/miyabi-tui/src/notification.rs new file mode 100644 index 0000000..30263eb --- /dev/null +++ b/crates/miyabi-tui/src/notification.rs @@ -0,0 +1,1528 @@ +//! Notification system for the TUI +//! +//! Provides alerts, banners, notification center, and history management. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, +}; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; +use uuid::Uuid; + +use crate::ui::colors; + +/// Notification priority levels +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum NotificationPriority { + /// Low priority - informational + Low, + /// Normal priority - standard notifications + Normal, + /// High priority - important notifications + High, + /// Critical priority - requires immediate attention + Critical, +} + +impl NotificationPriority { + /// Get color for this priority + pub fn color(&self) -> Color { + match self { + Self::Low => colors::COMMENT, + Self::Normal => colors::FG, + Self::High => colors::YELLOW, + Self::Critical => colors::RED, + } + } + + /// Get icon for this priority + pub fn icon(&self) -> &'static str { + match self { + Self::Low => "ℹ", + Self::Normal => "●", + Self::High => "⚠", + Self::Critical => "🔴", + } + } +} + +/// A single notification +#[derive(Debug, Clone)] +pub struct Notification { + /// Unique identifier + pub id: String, + /// Title of the notification + pub title: String, + /// Message content + pub message: String, + /// Priority level + pub priority: NotificationPriority, + /// Source of the notification + pub source: Option, + /// When the notification was created + pub created_at: Instant, + /// Duration to show (None = until dismissed) + pub duration: Option, + /// Whether the notification has been read + pub read: bool, + /// Actions available + pub actions: Vec, +} + +impl Notification { + /// Create a new notification + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + id: Uuid::new_v4().to_string(), + title: title.into(), + message: message.into(), + priority: NotificationPriority::Normal, + source: None, + created_at: Instant::now(), + duration: Some(Duration::from_secs(5)), + read: false, + actions: Vec::new(), + } + } + + /// Set priority + pub fn with_priority(mut self, priority: NotificationPriority) -> Self { + self.priority = priority; + self + } + + /// Set source + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Set duration (None for persistent) + pub fn with_duration(mut self, duration: Option) -> Self { + self.duration = duration; + self + } + + /// Add an action + pub fn with_action(mut self, action: NotificationAction) -> Self { + self.actions.push(action); + self + } + + /// Check if notification has expired + pub fn is_expired(&self) -> bool { + if let Some(duration) = self.duration { + self.created_at.elapsed() >= duration + } else { + false + } + } + + /// Get age of notification + pub fn age(&self) -> Duration { + self.created_at.elapsed() + } + + /// Format age for display + pub fn age_string(&self) -> String { + let secs = self.age().as_secs(); + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } +} + +/// Action that can be taken on a notification +#[derive(Debug, Clone)] +pub struct NotificationAction { + /// Unique identifier + pub id: String, + /// Display label + pub label: String, + /// Keyboard shortcut + pub shortcut: Option, + /// Whether this is the primary action + pub primary: bool, +} + +impl NotificationAction { + /// Create a new action + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + shortcut: None, + primary: false, + } + } + + /// Set as primary action + pub fn primary(mut self) -> Self { + self.primary = true; + self + } + + /// Set keyboard shortcut + pub fn with_shortcut(mut self, shortcut: char) -> Self { + self.shortcut = Some(shortcut); + self + } +} + +/// Result of notification panel actions +#[derive(Debug, Clone)] +pub enum NotificationPanelAction { + /// No action taken + None, + /// Close the panel + Close, + /// Dismiss notification by ID + Dismiss(String), + /// Dismiss all notifications + DismissAll, + /// Execute action on notification + ExecuteAction { notification_id: String, action_id: String }, + /// Mark all as read + MarkAllRead, +} + +/// Notification panel UI +#[derive(Debug)] +pub struct NotificationPanel { + /// All notifications + notifications: VecDeque, + /// Selected index + selected: usize, + /// List state for scrolling + list_state: ListState, + /// Maximum notifications to keep + max_notifications: usize, + /// Whether panel is visible + pub visible: bool, + /// Filter by priority + filter_priority: Option, + /// Show only unread + filter_unread: bool, +} + +impl Default for NotificationPanel { + fn default() -> Self { + Self::new() + } +} + +impl NotificationPanel { + /// Create a new notification panel + pub fn new() -> Self { + Self { + notifications: VecDeque::new(), + selected: 0, + list_state: ListState::default(), + max_notifications: 100, + visible: false, + filter_priority: None, + filter_unread: false, + } + } + + /// Add a notification + pub fn push(&mut self, notification: Notification) { + self.notifications.push_front(notification); + + // Trim if over limit + while self.notifications.len() > self.max_notifications { + self.notifications.pop_back(); + } + } + + /// Get filtered notifications + fn filtered(&self) -> Vec<&Notification> { + self.notifications + .iter() + .filter(|n| { + if self.filter_unread && n.read { + return false; + } + if let Some(priority) = self.filter_priority { + if n.priority != priority { + return false; + } + } + true + }) + .collect() + } + + /// Get unread count + pub fn unread_count(&self) -> usize { + self.notifications.iter().filter(|n| !n.read).count() + } + + /// Get total count + pub fn count(&self) -> usize { + self.notifications.len() + } + + /// Remove expired notifications + pub fn cleanup_expired(&mut self) { + self.notifications.retain(|n| !n.is_expired()); + } + + /// Handle key event + pub fn handle_key(&mut self, key: KeyEvent) -> NotificationPanelAction { + let filtered = self.filtered(); + let count = filtered.len(); + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.visible = false; + NotificationPanelAction::Close + } + KeyCode::Up | KeyCode::Char('k') => { + if self.selected > 0 { + self.selected -= 1; + self.list_state.select(Some(self.selected)); + } + NotificationPanelAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + if count > 0 && self.selected < count - 1 { + self.selected += 1; + self.list_state.select(Some(self.selected)); + } + NotificationPanelAction::None + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected = 0; + self.list_state.select(Some(0)); + NotificationPanelAction::None + } + KeyCode::End | KeyCode::Char('G') => { + if count > 0 { + self.selected = count - 1; + self.list_state.select(Some(self.selected)); + } + NotificationPanelAction::None + } + KeyCode::Char('d') | KeyCode::Delete => { + if let Some(notification) = filtered.get(self.selected) { + let id = notification.id.clone(); + NotificationPanelAction::Dismiss(id) + } else { + NotificationPanelAction::None + } + } + KeyCode::Char('D') if key.modifiers.contains(KeyModifiers::SHIFT) => { + NotificationPanelAction::DismissAll + } + KeyCode::Char('r') => { + // Mark selected as read + if let Some(notification) = filtered.get(self.selected) { + let id = notification.id.clone(); + if let Some(n) = self.notifications.iter_mut().find(|n| n.id == id) { + n.read = true; + } + } + NotificationPanelAction::None + } + KeyCode::Char('R') if key.modifiers.contains(KeyModifiers::SHIFT) => { + NotificationPanelAction::MarkAllRead + } + KeyCode::Char('u') => { + // Toggle unread filter + self.filter_unread = !self.filter_unread; + self.selected = 0; + self.list_state.select(Some(0)); + NotificationPanelAction::None + } + KeyCode::Enter => { + // Execute primary action + if let Some(notification) = filtered.get(self.selected) { + if let Some(action) = notification.actions.iter().find(|a| a.primary) { + return NotificationPanelAction::ExecuteAction { + notification_id: notification.id.clone(), + action_id: action.id.clone(), + }; + } + } + NotificationPanelAction::None + } + _ => NotificationPanelAction::None, + } + } + + /// Dismiss notification by ID + pub fn dismiss(&mut self, id: &str) { + self.notifications.retain(|n| n.id != id); + let count = self.filtered().len(); + if self.selected >= count && count > 0 { + self.selected = count - 1; + } + } + + /// Dismiss all notifications + pub fn dismiss_all(&mut self) { + self.notifications.clear(); + self.selected = 0; + } + + /// Mark all as read + pub fn mark_all_read(&mut self) { + for notification in &mut self.notifications { + notification.read = true; + } + } + + /// Render the notification panel + pub fn render(&mut self, area: Rect, buf: &mut Buffer) { + // Clear background + Clear.render(area, buf); + + // Draw border + let block = Block::default() + .title(format!(" Notifications ({}) ", self.unread_count())) + .title_style(Style::default().fg(colors::CYAN).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(colors::SELECTION)); + + let inner = block.inner(area); + block.render(area, buf); + + if self.notifications.is_empty() { + let empty = Paragraph::new("No notifications") + .style(Style::default().fg(colors::COMMENT)) + .alignment(Alignment::Center); + let centered = Rect { + y: inner.y + inner.height / 2, + height: 1, + ..inner + }; + empty.render(centered, buf); + return; + } + + // Build list items + let filtered = self.filtered(); + let items: Vec = filtered + .iter() + .enumerate() + .map(|(i, notification)| { + let is_selected = i == self.selected; + + // Build content + let icon = notification.priority.icon(); + let read_indicator = if notification.read { " " } else { "•" }; + let age = notification.age_string(); + + let header = format!( + "{} {} {} - {}", + read_indicator, + icon, + notification.title, + age + ); + + let mut lines = vec![ + Line::from(Span::styled( + header, + Style::default() + .fg(notification.priority.color()) + .add_modifier(if is_selected { + Modifier::BOLD + } else if notification.read { + Modifier::DIM + } else { + Modifier::empty() + }), + )), + ]; + + // Add message (truncated) + let msg = if notification.message.len() > 60 { + format!(" {}...", ¬ification.message[..57]) + } else { + format!(" {}", notification.message) + }; + lines.push(Line::from(Span::styled( + msg, + Style::default().fg(colors::FG).add_modifier( + if notification.read { + Modifier::DIM + } else { + Modifier::empty() + }, + ), + ))); + + // Add actions if selected + if is_selected && !notification.actions.is_empty() { + let actions_str: Vec = notification + .actions + .iter() + .map(|a| { + if let Some(shortcut) = a.shortcut { + format!("[{}] {}", shortcut, a.label) + } else { + a.label.clone() + } + }) + .collect(); + lines.push(Line::from(Span::styled( + format!(" {}", actions_str.join(" | ")), + Style::default().fg(colors::CYAN), + ))); + } + + ListItem::new(lines) + }) + .collect(); + + let list = List::new(items) + .highlight_style(Style::default().bg(colors::SELECTION)); + + // Render with state + ratatui::widgets::StatefulWidget::render(list, inner, buf, &mut self.list_state); + + // Draw help at bottom + let help = " ↑↓:Navigate | d:Dismiss | D:Dismiss All | r:Read | R:Read All | u:Toggle Unread | q:Close "; + let help_area = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + Paragraph::new(help) + .style(Style::default().fg(colors::COMMENT)) + .render(help_area, buf); + } +} + +/// A banner notification (non-blocking, appears at top/bottom) +#[derive(Debug, Clone)] +pub struct Banner { + /// Message to display + pub message: String, + /// Priority level + pub priority: NotificationPriority, + /// When banner was created + pub created_at: Instant, + /// Duration to show + pub duration: Duration, + /// Whether banner is dismissible + pub dismissible: bool, + /// Progress value (0.0 - 1.0) for progress banners + pub progress: Option, +} + +impl Banner { + /// Create a new banner + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + priority: NotificationPriority::Normal, + created_at: Instant::now(), + duration: Duration::from_secs(3), + dismissible: true, + progress: None, + } + } + + /// Set priority + pub fn with_priority(mut self, priority: NotificationPriority) -> Self { + self.priority = priority; + self + } + + /// Set duration + pub fn with_duration(mut self, duration: Duration) -> Self { + self.duration = duration; + self + } + + /// Set as progress banner + pub fn with_progress(mut self, progress: f64) -> Self { + self.progress = Some(progress.clamp(0.0, 1.0)); + self + } + + /// Check if banner has expired + pub fn is_expired(&self) -> bool { + self.created_at.elapsed() >= self.duration + } + + /// Render the banner + pub fn render(&self, area: Rect, buf: &mut Buffer) { + let bg_color = match self.priority { + NotificationPriority::Low => colors::COMMENT, + NotificationPriority::Normal => colors::SELECTION, + NotificationPriority::High => colors::YELLOW, + NotificationPriority::Critical => colors::RED, + }; + + let fg_color = match self.priority { + NotificationPriority::Low | NotificationPriority::Normal => colors::FG, + NotificationPriority::High | NotificationPriority::Critical => colors::BG, + }; + + // Fill background + for x in area.x..area.x + area.width { + for y in area.y..area.y + area.height { + if let Some(cell) = buf.cell_mut(Position { x, y }) { + cell.set_bg(bg_color); + } + } + } + + // Draw message + let icon = self.priority.icon(); + let text = format!(" {} {} ", icon, self.message); + + let paragraph = Paragraph::new(text) + .style(Style::default().fg(fg_color).bg(bg_color)) + .alignment(Alignment::Center); + paragraph.render(area, buf); + + // Draw progress bar if present + if let Some(progress) = self.progress { + if area.height > 1 { + let progress_area = Rect { + y: area.y + 1, + height: 1, + ..area + }; + let filled = (progress * progress_area.width as f64) as u16; + + for x in progress_area.x..progress_area.x + filled { + if let Some(cell) = buf.cell_mut(Position { x, y: progress_area.y }) { + cell.set_bg(colors::GREEN); + } + } + } + } + } +} + +/// Alert dialog (blocking, modal) +#[derive(Debug, Clone)] +pub struct Alert { + /// Title + pub title: String, + /// Message + pub message: String, + /// Alert type + pub alert_type: AlertType, + /// Buttons + pub buttons: Vec, + /// Currently selected button + selected_button: usize, +} + +/// Type of alert +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlertType { + /// Informational alert + Info, + /// Success alert + Success, + /// Warning alert + Warning, + /// Error alert + Error, + /// Confirmation alert + Confirm, +} + +impl AlertType { + /// Get color for this alert type + pub fn color(&self) -> Color { + match self { + Self::Info => colors::CYAN, + Self::Success => colors::GREEN, + Self::Warning => colors::YELLOW, + Self::Error => colors::RED, + Self::Confirm => colors::MAGENTA, + } + } + + /// Get icon for this alert type + pub fn icon(&self) -> &'static str { + match self { + Self::Info => "ℹ", + Self::Success => "✓", + Self::Warning => "⚠", + Self::Error => "✗", + Self::Confirm => "?", + } + } +} + +/// Alert button +#[derive(Debug, Clone)] +pub struct AlertButton { + /// Button ID + pub id: String, + /// Button label + pub label: String, + /// Whether this is the primary button + pub primary: bool, +} + +impl AlertButton { + /// Create a new button + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + primary: false, + } + } + + /// Set as primary button + pub fn primary(mut self) -> Self { + self.primary = true; + self + } +} + +/// Result of alert actions +#[derive(Debug, Clone)] +pub enum AlertAction { + /// No action + None, + /// Button pressed + ButtonPressed(String), + /// Alert cancelled + Cancelled, +} + +impl Alert { + /// Create a new alert + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: message.into(), + alert_type: AlertType::Info, + buttons: vec![AlertButton::new("ok", "OK").primary()], + selected_button: 0, + } + } + + /// Create an info alert + pub fn info(title: impl Into, message: impl Into) -> Self { + Self::new(title, message).with_type(AlertType::Info) + } + + /// Create a success alert + pub fn success(title: impl Into, message: impl Into) -> Self { + Self::new(title, message).with_type(AlertType::Success) + } + + /// Create a warning alert + pub fn warning(title: impl Into, message: impl Into) -> Self { + Self::new(title, message).with_type(AlertType::Warning) + } + + /// Create an error alert + pub fn error(title: impl Into, message: impl Into) -> Self { + Self::new(title, message).with_type(AlertType::Error) + } + + /// Create a confirmation alert + pub fn confirm(title: impl Into, message: impl Into) -> Self { + Self::new(title, message) + .with_type(AlertType::Confirm) + .with_buttons(vec![ + AlertButton::new("cancel", "Cancel"), + AlertButton::new("confirm", "Confirm").primary(), + ]) + } + + /// Set alert type + pub fn with_type(mut self, alert_type: AlertType) -> Self { + self.alert_type = alert_type; + self + } + + /// Set buttons + pub fn with_buttons(mut self, buttons: Vec) -> Self { + self.buttons = buttons; + self.selected_button = 0; + self + } + + /// Handle key event + pub fn handle_key(&mut self, key: KeyEvent) -> AlertAction { + match key.code { + KeyCode::Esc => AlertAction::Cancelled, + KeyCode::Left | KeyCode::Char('h') => { + if self.selected_button > 0 { + self.selected_button -= 1; + } + AlertAction::None + } + KeyCode::Right | KeyCode::Char('l') => { + if self.selected_button < self.buttons.len().saturating_sub(1) { + self.selected_button += 1; + } + AlertAction::None + } + KeyCode::Tab => { + self.selected_button = (self.selected_button + 1) % self.buttons.len(); + AlertAction::None + } + KeyCode::Enter => { + if let Some(button) = self.buttons.get(self.selected_button) { + AlertAction::ButtonPressed(button.id.clone()) + } else { + AlertAction::None + } + } + _ => AlertAction::None, + } + } + + /// Render the alert + pub fn render(&self, area: Rect, buf: &mut Buffer) { + // Calculate alert size + let width = 50.min(area.width.saturating_sub(4)); + let height = 8.min(area.height.saturating_sub(4)); + + let alert_area = Rect { + x: area.x + (area.width - width) / 2, + y: area.y + (area.height - height) / 2, + width, + height, + }; + + // Clear background + Clear.render(alert_area, buf); + + // Draw border + let icon = self.alert_type.icon(); + let color = self.alert_type.color(); + + let block = Block::default() + .title(format!(" {} {} ", icon, self.title)) + .title_style(Style::default().fg(color).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(color)); + + let inner = block.inner(alert_area); + block.render(alert_area, buf); + + // Draw message + let message_area = Rect { + height: inner.height.saturating_sub(2), + ..inner + }; + let message = Paragraph::new(self.message.as_str()) + .style(Style::default().fg(colors::FG)) + .wrap(Wrap { trim: true }); + message.render(message_area, buf); + + // Draw buttons + let button_area = Rect { + y: inner.y + inner.height - 1, + height: 1, + ..inner + }; + + let mut x = button_area.x; + for (i, button) in self.buttons.iter().enumerate() { + let is_selected = i == self.selected_button; + let style = if is_selected { + Style::default().fg(colors::BG).bg(color).bold() + } else if button.primary { + Style::default().fg(color) + } else { + Style::default().fg(colors::FG) + }; + + let label = format!(" {} ", button.label); + let span = Span::styled(label, style); + let label_width = button.label.len() as u16 + 2; + + if x + label_width <= button_area.x + button_area.width { + buf.set_span(x, button_area.y, &span, label_width); + x += label_width + 1; + } + } + } +} + +/// Notification center that manages all notifications +#[derive(Debug)] +pub struct NotificationCenter { + /// Active banners + pub banners: VecDeque, + /// Notification panel + pub panel: NotificationPanel, + /// Current alert + pub alert: Option, +} + +impl Default for NotificationCenter { + fn default() -> Self { + Self::new() + } +} + +impl NotificationCenter { + /// Create a new notification center + pub fn new() -> Self { + Self { + banners: VecDeque::new(), + panel: NotificationPanel::new(), + alert: None, + } + } + + /// Push a notification to the panel + pub fn notify(&mut self, notification: Notification) { + self.panel.push(notification); + } + + /// Show a banner + pub fn show_banner(&mut self, banner: Banner) { + self.banners.push_back(banner); + } + + /// Show an alert + pub fn show_alert(&mut self, alert: Alert) { + self.alert = Some(alert); + } + + /// Cleanup expired items + pub fn cleanup(&mut self) { + self.banners.retain(|b| !b.is_expired()); + self.panel.cleanup_expired(); + } + + /// Check if there are any active overlays + pub fn has_overlay(&self) -> bool { + self.alert.is_some() || self.panel.visible + } + + /// Get unread notification count + pub fn unread_count(&self) -> usize { + self.panel.unread_count() + } + + /// Convenience method for info notification + pub fn info(&mut self, title: impl Into, message: impl Into) { + self.notify( + Notification::new(title, message) + .with_priority(NotificationPriority::Low) + ); + } + + /// Convenience method for success notification + pub fn success(&mut self, title: impl Into, message: impl Into) { + self.notify( + Notification::new(title, message) + .with_priority(NotificationPriority::Normal) + ); + } + + /// Convenience method for warning notification + pub fn warning(&mut self, title: impl Into, message: impl Into) { + self.notify( + Notification::new(title, message) + .with_priority(NotificationPriority::High) + ); + } + + /// Convenience method for error notification + pub fn error(&mut self, title: impl Into, message: impl Into) { + self.notify( + Notification::new(title, message) + .with_priority(NotificationPriority::Critical) + .with_duration(None) // Errors persist until dismissed + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // NotificationPriority tests + #[test] + fn test_priority_order() { + assert!(NotificationPriority::Low < NotificationPriority::Normal); + assert!(NotificationPriority::Normal < NotificationPriority::High); + assert!(NotificationPriority::High < NotificationPriority::Critical); + } + + #[test] + fn test_priority_color() { + assert_eq!(NotificationPriority::Low.color(), colors::COMMENT); + assert_eq!(NotificationPriority::Normal.color(), colors::FG); + assert_eq!(NotificationPriority::High.color(), colors::YELLOW); + assert_eq!(NotificationPriority::Critical.color(), colors::RED); + } + + #[test] + fn test_priority_icon() { + assert_eq!(NotificationPriority::Low.icon(), "ℹ"); + assert_eq!(NotificationPriority::Normal.icon(), "●"); + assert_eq!(NotificationPriority::High.icon(), "⚠"); + assert_eq!(NotificationPriority::Critical.icon(), "🔴"); + } + + // Notification tests + #[test] + fn test_notification_creation() { + let notification = Notification::new("Test Title", "Test Message"); + assert_eq!(notification.title, "Test Title"); + assert_eq!(notification.message, "Test Message"); + assert_eq!(notification.priority, NotificationPriority::Normal); + assert!(!notification.read); + assert!(notification.actions.is_empty()); + } + + #[test] + fn test_notification_with_priority() { + let notification = Notification::new("Title", "Message") + .with_priority(NotificationPriority::Critical); + assert_eq!(notification.priority, NotificationPriority::Critical); + } + + #[test] + fn test_notification_with_source() { + let notification = Notification::new("Title", "Message") + .with_source("system"); + assert_eq!(notification.source, Some("system".to_string())); + } + + #[test] + fn test_notification_with_duration() { + let notification = Notification::new("Title", "Message") + .with_duration(Some(Duration::from_secs(10))); + assert_eq!(notification.duration, Some(Duration::from_secs(10))); + + let persistent = Notification::new("Title", "Message") + .with_duration(None); + assert_eq!(persistent.duration, None); + } + + #[test] + fn test_notification_with_action() { + let notification = Notification::new("Title", "Message") + .with_action(NotificationAction::new("view", "View")); + assert_eq!(notification.actions.len(), 1); + assert_eq!(notification.actions[0].id, "view"); + } + + #[test] + fn test_notification_is_expired() { + // Short duration notification + let notification = Notification::new("Title", "Message") + .with_duration(Some(Duration::from_millis(1))); + std::thread::sleep(Duration::from_millis(5)); + assert!(notification.is_expired()); + + // Persistent notification never expires + let persistent = Notification::new("Title", "Message") + .with_duration(None); + assert!(!persistent.is_expired()); + } + + #[test] + fn test_notification_age() { + let notification = Notification::new("Title", "Message"); + std::thread::sleep(Duration::from_millis(10)); + assert!(notification.age().as_millis() >= 10); + } + + #[test] + fn test_notification_age_string() { + let notification = Notification::new("Title", "Message"); + let age_str = notification.age_string(); + assert!(age_str.contains("s ago")); + } + + // NotificationAction tests + #[test] + fn test_notification_action_creation() { + let action = NotificationAction::new("dismiss", "Dismiss"); + assert_eq!(action.id, "dismiss"); + assert_eq!(action.label, "Dismiss"); + assert!(!action.primary); + assert!(action.shortcut.is_none()); + } + + #[test] + fn test_notification_action_primary() { + let action = NotificationAction::new("ok", "OK").primary(); + assert!(action.primary); + } + + #[test] + fn test_notification_action_with_shortcut() { + let action = NotificationAction::new("view", "View").with_shortcut('v'); + assert_eq!(action.shortcut, Some('v')); + } + + // NotificationPanel tests + #[test] + fn test_panel_creation() { + let panel = NotificationPanel::new(); + assert_eq!(panel.count(), 0); + assert_eq!(panel.unread_count(), 0); + assert!(!panel.visible); + } + + #[test] + fn test_panel_push() { + let mut panel = NotificationPanel::new(); + panel.push(Notification::new("Test", "Message")); + assert_eq!(panel.count(), 1); + assert_eq!(panel.unread_count(), 1); + } + + #[test] + fn test_panel_max_notifications() { + let mut panel = NotificationPanel::new(); + panel.max_notifications = 3; + + for i in 0..5 { + panel.push(Notification::new(format!("Title {}", i), "Message")); + } + + assert_eq!(panel.count(), 3); + } + + #[test] + fn test_panel_dismiss() { + let mut panel = NotificationPanel::new(); + let notification = Notification::new("Test", "Message"); + let id = notification.id.clone(); + panel.push(notification); + + assert_eq!(panel.count(), 1); + panel.dismiss(&id); + assert_eq!(panel.count(), 0); + } + + #[test] + fn test_panel_dismiss_all() { + let mut panel = NotificationPanel::new(); + panel.push(Notification::new("Test 1", "Message")); + panel.push(Notification::new("Test 2", "Message")); + + panel.dismiss_all(); + assert_eq!(panel.count(), 0); + } + + #[test] + fn test_panel_mark_all_read() { + let mut panel = NotificationPanel::new(); + panel.push(Notification::new("Test 1", "Message")); + panel.push(Notification::new("Test 2", "Message")); + + assert_eq!(panel.unread_count(), 2); + panel.mark_all_read(); + assert_eq!(panel.unread_count(), 0); + } + + #[test] + fn test_panel_cleanup_expired() { + let mut panel = NotificationPanel::new(); + panel.push( + Notification::new("Test", "Message") + .with_duration(Some(Duration::from_millis(1))) + ); + + std::thread::sleep(Duration::from_millis(5)); + panel.cleanup_expired(); + assert_eq!(panel.count(), 0); + } + + #[test] + fn test_panel_handle_key_close() { + let mut panel = NotificationPanel::new(); + panel.visible = true; + + let action = panel.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + assert!(matches!(action, NotificationPanelAction::Close)); + assert!(!panel.visible); + + panel.visible = true; + let action = panel.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())); + assert!(matches!(action, NotificationPanelAction::Close)); + } + + #[test] + fn test_panel_handle_key_navigation() { + let mut panel = NotificationPanel::new(); + panel.push(Notification::new("Test 1", "Message")); + panel.push(Notification::new("Test 2", "Message")); + + // Navigate down + panel.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())); + assert_eq!(panel.selected, 1); + + // Navigate up + panel.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())); + assert_eq!(panel.selected, 0); + + // Navigate with j/k + panel.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty())); + assert_eq!(panel.selected, 1); + + panel.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::empty())); + assert_eq!(panel.selected, 0); + } + + #[test] + fn test_panel_handle_key_home_end() { + let mut panel = NotificationPanel::new(); + for i in 0..5 { + panel.push(Notification::new(format!("Test {}", i), "Message")); + } + + // Go to end + panel.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::empty())); + assert_eq!(panel.selected, 4); + + // Go to home + panel.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::empty())); + assert_eq!(panel.selected, 0); + } + + #[test] + fn test_panel_handle_key_dismiss() { + let mut panel = NotificationPanel::new(); + let notification = Notification::new("Test", "Message"); + let id = notification.id.clone(); + panel.push(notification); + + let action = panel.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty())); + assert!(matches!(action, NotificationPanelAction::Dismiss(ref i) if i == &id)); + } + + #[test] + fn test_panel_handle_key_dismiss_all() { + let mut panel = NotificationPanel::new(); + panel.push(Notification::new("Test", "Message")); + + let action = panel.handle_key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT)); + assert!(matches!(action, NotificationPanelAction::DismissAll)); + } + + #[test] + fn test_panel_handle_key_mark_all_read() { + let mut panel = NotificationPanel::new(); + panel.push(Notification::new("Test", "Message")); + + let action = panel.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert!(matches!(action, NotificationPanelAction::MarkAllRead)); + } + + #[test] + fn test_panel_handle_key_toggle_unread() { + let mut panel = NotificationPanel::new(); + assert!(!panel.filter_unread); + + panel.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty())); + assert!(panel.filter_unread); + + panel.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty())); + assert!(!panel.filter_unread); + } + + // Banner tests + #[test] + fn test_banner_creation() { + let banner = Banner::new("Test message"); + assert_eq!(banner.message, "Test message"); + assert_eq!(banner.priority, NotificationPriority::Normal); + assert!(banner.dismissible); + assert!(banner.progress.is_none()); + } + + #[test] + fn test_banner_with_priority() { + let banner = Banner::new("Test").with_priority(NotificationPriority::High); + assert_eq!(banner.priority, NotificationPriority::High); + } + + #[test] + fn test_banner_with_duration() { + let banner = Banner::new("Test").with_duration(Duration::from_secs(10)); + assert_eq!(banner.duration, Duration::from_secs(10)); + } + + #[test] + fn test_banner_with_progress() { + let banner = Banner::new("Test").with_progress(0.5); + assert_eq!(banner.progress, Some(0.5)); + + // Test clamping + let banner = Banner::new("Test").with_progress(1.5); + assert_eq!(banner.progress, Some(1.0)); + + let banner = Banner::new("Test").with_progress(-0.5); + assert_eq!(banner.progress, Some(0.0)); + } + + #[test] + fn test_banner_is_expired() { + let banner = Banner::new("Test").with_duration(Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(5)); + assert!(banner.is_expired()); + } + + // AlertType tests + #[test] + fn test_alert_type_color() { + assert_eq!(AlertType::Info.color(), colors::CYAN); + assert_eq!(AlertType::Success.color(), colors::GREEN); + assert_eq!(AlertType::Warning.color(), colors::YELLOW); + assert_eq!(AlertType::Error.color(), colors::RED); + assert_eq!(AlertType::Confirm.color(), colors::MAGENTA); + } + + #[test] + fn test_alert_type_icon() { + assert_eq!(AlertType::Info.icon(), "ℹ"); + assert_eq!(AlertType::Success.icon(), "✓"); + assert_eq!(AlertType::Warning.icon(), "⚠"); + assert_eq!(AlertType::Error.icon(), "✗"); + assert_eq!(AlertType::Confirm.icon(), "?"); + } + + // AlertButton tests + #[test] + fn test_alert_button_creation() { + let button = AlertButton::new("ok", "OK"); + assert_eq!(button.id, "ok"); + assert_eq!(button.label, "OK"); + assert!(!button.primary); + } + + #[test] + fn test_alert_button_primary() { + let button = AlertButton::new("ok", "OK").primary(); + assert!(button.primary); + } + + // Alert tests + #[test] + fn test_alert_creation() { + let alert = Alert::new("Title", "Message"); + assert_eq!(alert.title, "Title"); + assert_eq!(alert.message, "Message"); + assert_eq!(alert.alert_type, AlertType::Info); + assert_eq!(alert.buttons.len(), 1); + assert_eq!(alert.buttons[0].label, "OK"); + } + + #[test] + fn test_alert_info() { + let alert = Alert::info("Info", "Message"); + assert_eq!(alert.alert_type, AlertType::Info); + } + + #[test] + fn test_alert_success() { + let alert = Alert::success("Success", "Message"); + assert_eq!(alert.alert_type, AlertType::Success); + } + + #[test] + fn test_alert_warning() { + let alert = Alert::warning("Warning", "Message"); + assert_eq!(alert.alert_type, AlertType::Warning); + } + + #[test] + fn test_alert_error() { + let alert = Alert::error("Error", "Message"); + assert_eq!(alert.alert_type, AlertType::Error); + } + + #[test] + fn test_alert_confirm() { + let alert = Alert::confirm("Confirm", "Are you sure?"); + assert_eq!(alert.alert_type, AlertType::Confirm); + assert_eq!(alert.buttons.len(), 2); + assert_eq!(alert.buttons[0].label, "Cancel"); + assert_eq!(alert.buttons[1].label, "Confirm"); + } + + #[test] + fn test_alert_with_type() { + let alert = Alert::new("Title", "Message").with_type(AlertType::Error); + assert_eq!(alert.alert_type, AlertType::Error); + } + + #[test] + fn test_alert_with_buttons() { + let alert = Alert::new("Title", "Message").with_buttons(vec![ + AlertButton::new("yes", "Yes"), + AlertButton::new("no", "No"), + ]); + assert_eq!(alert.buttons.len(), 2); + } + + #[test] + fn test_alert_handle_key_escape() { + let mut alert = Alert::new("Title", "Message"); + let action = alert.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); + assert!(matches!(action, AlertAction::Cancelled)); + } + + #[test] + fn test_alert_handle_key_navigation() { + let mut alert = Alert::new("Title", "Message").with_buttons(vec![ + AlertButton::new("a", "A"), + AlertButton::new("b", "B"), + AlertButton::new("c", "C"), + ]); + + // Navigate right + alert.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())); + assert_eq!(alert.selected_button, 1); + + // Navigate left + alert.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())); + assert_eq!(alert.selected_button, 0); + + // Tab cycles + alert.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())); + assert_eq!(alert.selected_button, 1); + alert.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())); + assert_eq!(alert.selected_button, 2); + alert.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())); + assert_eq!(alert.selected_button, 0); + } + + #[test] + fn test_alert_handle_key_enter() { + let mut alert = Alert::new("Title", "Message").with_buttons(vec![ + AlertButton::new("ok", "OK"), + ]); + + let action = alert.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + assert!(matches!(action, AlertAction::ButtonPressed(ref id) if id == "ok")); + } + + // NotificationCenter tests + #[test] + fn test_center_creation() { + let center = NotificationCenter::new(); + assert!(center.banners.is_empty()); + assert!(center.alert.is_none()); + assert_eq!(center.unread_count(), 0); + } + + #[test] + fn test_center_notify() { + let mut center = NotificationCenter::new(); + center.notify(Notification::new("Test", "Message")); + assert_eq!(center.panel.count(), 1); + assert_eq!(center.unread_count(), 1); + } + + #[test] + fn test_center_show_banner() { + let mut center = NotificationCenter::new(); + center.show_banner(Banner::new("Test")); + assert_eq!(center.banners.len(), 1); + } + + #[test] + fn test_center_show_alert() { + let mut center = NotificationCenter::new(); + center.show_alert(Alert::new("Title", "Message")); + assert!(center.alert.is_some()); + } + + #[test] + fn test_center_has_overlay() { + let mut center = NotificationCenter::new(); + assert!(!center.has_overlay()); + + center.show_alert(Alert::new("Title", "Message")); + assert!(center.has_overlay()); + + center.alert = None; + center.panel.visible = true; + assert!(center.has_overlay()); + } + + #[test] + fn test_center_cleanup() { + let mut center = NotificationCenter::new(); + center.show_banner(Banner::new("Test").with_duration(Duration::from_millis(1))); + center.notify( + Notification::new("Test", "Message") + .with_duration(Some(Duration::from_millis(1))) + ); + + std::thread::sleep(Duration::from_millis(5)); + center.cleanup(); + + assert!(center.banners.is_empty()); + assert_eq!(center.panel.count(), 0); + } + + #[test] + fn test_center_convenience_info() { + let mut center = NotificationCenter::new(); + center.info("Info", "Message"); + assert_eq!(center.panel.count(), 1); + } + + #[test] + fn test_center_convenience_success() { + let mut center = NotificationCenter::new(); + center.success("Success", "Message"); + assert_eq!(center.panel.count(), 1); + } + + #[test] + fn test_center_convenience_warning() { + let mut center = NotificationCenter::new(); + center.warning("Warning", "Message"); + assert_eq!(center.panel.count(), 1); + } + + #[test] + fn test_center_convenience_error() { + let mut center = NotificationCenter::new(); + center.error("Error", "Message"); + assert_eq!(center.panel.count(), 1); + } + + #[test] + fn test_notification_panel_action_enum() { + // Just test the enum variants exist + let _none = NotificationPanelAction::None; + let _close = NotificationPanelAction::Close; + let _dismiss = NotificationPanelAction::Dismiss("id".to_string()); + let _dismiss_all = NotificationPanelAction::DismissAll; + let _execute = NotificationPanelAction::ExecuteAction { + notification_id: "n".to_string(), + action_id: "a".to_string(), + }; + let _mark_read = NotificationPanelAction::MarkAllRead; + } + + #[test] + fn test_alert_action_enum() { + let _none = AlertAction::None; + let _pressed = AlertAction::ButtonPressed("ok".to_string()); + let _cancelled = AlertAction::Cancelled; + } +} diff --git a/crates/miyabi-tui/src/views.rs b/crates/miyabi-tui/src/views.rs new file mode 100644 index 0000000..d31e8d3 --- /dev/null +++ b/crates/miyabi-tui/src/views.rs @@ -0,0 +1,906 @@ +//! Main View Orchestration +//! +//! Coordinates all UI components, handles layout, event routing, and state management. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, +}; +use std::time::Instant; + +use crate::{ + approval_overlay::{ApprovalAction, ApprovalOverlay, ApprovalRequest}, + chat_composer::{ChatComposer, ComposerAction}, + command_popup::{CommandPopup, CommandPopupAction}, + help::{HelpAction, HelpViewer}, + history_cell::HistoryCell, + notification::{Alert, AlertAction, Notification, NotificationCenter, NotificationPanelAction}, + pager_overlay::{PagerAction, PagerOverlay, PagerContent}, + resume_picker::{ResumePicker, ResumePickerAction, SessionEntry}, + shimmer::Spinner, + ui::{colors, Breadcrumb, StatusBar, StatusItem}, +}; + +/// Focus area in the main view +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusArea { + /// Chat input area + Chat, + /// History/message area + History, + /// Sidebar (if visible) + Sidebar, +} + +/// Active overlay type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActiveOverlay { + /// No overlay active + None, + /// Command palette + CommandPalette, + /// Help viewer + Help, + /// Approval dialog + Approval, + /// Pager/viewer + Pager, + /// Session picker + SessionPicker, + /// Notification panel + Notifications, + /// Alert dialog + Alert, +} + +/// Application mode +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppMode { + /// Normal chat mode + Normal, + /// Streaming response + Streaming, + /// Waiting for approval + WaitingApproval, + /// Loading/processing + Loading, +} + +/// Actions that can be returned from the view +#[derive(Debug, Clone)] +pub enum ViewAction { + /// No action + None, + /// Quit the application + Quit, + /// Send message + SendMessage(String), + /// Execute command + ExecuteCommand(String), + /// Approve action + Approve { request_id: String, approved: bool }, + /// Resume session + ResumeSession(String), + /// Show notification + Notify(Notification), + /// Cancel current operation + Cancel, + /// Toggle sidebar + ToggleSidebar, + /// Open file + OpenFile(String), + /// Copy to clipboard + Copy(String), +} + +/// Layout configuration +#[derive(Debug, Clone)] +pub struct LayoutConfig { + /// Show sidebar + pub show_sidebar: bool, + /// Sidebar width (percentage) + pub sidebar_width: u16, + /// Show status bar + pub show_status_bar: bool, + /// Show breadcrumb + pub show_breadcrumb: bool, + /// Input height (lines) + pub input_height: u16, + /// Minimum history height + pub min_history_height: u16, +} + +impl Default for LayoutConfig { + fn default() -> Self { + Self { + show_sidebar: false, + sidebar_width: 25, + show_status_bar: true, + show_breadcrumb: true, + input_height: 3, + min_history_height: 10, + } + } +} + +/// Main application view +pub struct MainView { + /// Layout configuration + pub layout: LayoutConfig, + /// Current focus area + pub focus: FocusArea, + /// Active overlay + pub overlay: ActiveOverlay, + /// Application mode + pub mode: AppMode, + /// Chat composer + pub chat: ChatComposer, + /// Message history + pub history: Vec>, + /// History scroll position + pub history_scroll: usize, + /// Maximum scroll position + pub max_scroll: usize, + /// Notification center + pub notifications: NotificationCenter, + /// Command popup + pub command_popup: CommandPopup, + /// Help viewer + pub help_viewer: HelpViewer, + /// Approval overlay + pub approval_overlay: ApprovalOverlay, + /// Pager overlay + pub pager_overlay: PagerOverlay, + /// Session picker + pub session_picker: ResumePicker, + /// Loading spinner + pub spinner: Spinner, + /// Current working directory + pub cwd: String, + /// Session name + pub session_name: String, + /// Model name + pub model_name: String, + /// Token usage + pub tokens_used: usize, + /// Last activity time + pub last_activity: Instant, + /// Sidebar items + pub sidebar_items: Vec, + /// Selected sidebar item + pub sidebar_selected: usize, +} + +impl Default for MainView { + fn default() -> Self { + Self::new() + } +} + +impl MainView { + /// Create a new main view + pub fn new() -> Self { + Self { + layout: LayoutConfig::default(), + focus: FocusArea::Chat, + overlay: ActiveOverlay::None, + mode: AppMode::Normal, + chat: ChatComposer::new(), + history: Vec::new(), + history_scroll: 0, + max_scroll: 0, + notifications: NotificationCenter::new(), + command_popup: CommandPopup::new(), + help_viewer: HelpViewer::new(), + approval_overlay: ApprovalOverlay::new(), + pager_overlay: PagerOverlay::new(), + session_picker: ResumePicker::new(), + spinner: Spinner::new(), + cwd: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "~".to_string()), + session_name: "New Session".to_string(), + model_name: "claude-sonnet-4-20250514".to_string(), + tokens_used: 0, + last_activity: Instant::now(), + sidebar_items: Vec::new(), + sidebar_selected: 0, + } + } + + /// Set the session name + pub fn with_session(mut self, name: impl Into) -> Self { + self.session_name = name.into(); + self + } + + /// Set the model name + pub fn with_model(mut self, model: impl Into) -> Self { + self.model_name = model.into(); + self + } + + /// Add a message to history + pub fn push_message(&mut self, cell: Box) { + self.history.push(cell); + self.last_activity = Instant::now(); + } + + /// Show command palette + pub fn show_command_palette(&mut self) { + self.overlay = ActiveOverlay::CommandPalette; + self.command_popup.show(); + } + + /// Show help viewer + pub fn show_help(&mut self) { + self.overlay = ActiveOverlay::Help; + } + + /// Show approval dialog + pub fn show_approval(&mut self, request: ApprovalRequest) { + self.overlay = ActiveOverlay::Approval; + self.approval_overlay.show(request); + self.mode = AppMode::WaitingApproval; + } + + /// Show pager with content + pub fn show_pager(&mut self, content: PagerContent) { + self.overlay = ActiveOverlay::Pager; + self.pager_overlay = PagerOverlay::new().content(content); + } + + /// Show session picker + pub fn show_session_picker(&mut self, sessions: Vec) { + self.overlay = ActiveOverlay::SessionPicker; + for session in sessions { + self.session_picker.add_session(session); + } + } + + /// Show notification panel + pub fn show_notifications(&mut self) { + self.overlay = ActiveOverlay::Notifications; + self.notifications.panel.visible = true; + } + + /// Show alert dialog + pub fn show_alert(&mut self, alert: Alert) { + self.overlay = ActiveOverlay::Alert; + self.notifications.show_alert(alert); + } + + /// Close current overlay + pub fn close_overlay(&mut self) { + match self.overlay { + ActiveOverlay::CommandPalette => self.command_popup.hide(), + ActiveOverlay::Approval => { + self.approval_overlay.hide(); + self.mode = AppMode::Normal; + } + ActiveOverlay::Notifications => self.notifications.panel.visible = false, + ActiveOverlay::Alert => self.notifications.alert = None, + _ => {} + } + self.overlay = ActiveOverlay::None; + } + + /// Set streaming mode + pub fn set_streaming(&mut self, streaming: bool) { + self.mode = if streaming { + AppMode::Streaming + } else { + AppMode::Normal + }; + } + + /// Set loading mode + pub fn set_loading(&mut self, loading: bool) { + self.mode = if loading { + AppMode::Loading + } else { + AppMode::Normal + }; + } + + /// Handle keyboard input + pub fn handle_key(&mut self, key: KeyEvent) -> ViewAction { + self.last_activity = Instant::now(); + + // Handle overlay input first + if self.overlay != ActiveOverlay::None { + return self.handle_overlay_key(key); + } + + // Global shortcuts + match (key.modifiers, key.code) { + // Quit + (KeyModifiers::CONTROL, KeyCode::Char('c')) => { + if self.mode == AppMode::Streaming { + return ViewAction::Cancel; + } + return ViewAction::Quit; + } + // Command palette + (KeyModifiers::CONTROL, KeyCode::Char('p')) => { + self.show_command_palette(); + return ViewAction::None; + } + // Help + (KeyModifiers::NONE, KeyCode::F(1)) => { + self.show_help(); + return ViewAction::None; + } + // Toggle sidebar + (KeyModifiers::CONTROL, KeyCode::Char('b')) => { + self.layout.show_sidebar = !self.layout.show_sidebar; + return ViewAction::ToggleSidebar; + } + // Notifications + (KeyModifiers::CONTROL, KeyCode::Char('n')) => { + self.show_notifications(); + return ViewAction::None; + } + // Escape + (KeyModifiers::NONE, KeyCode::Esc) => { + if self.mode == AppMode::Streaming { + return ViewAction::Cancel; + } + } + _ => {} + } + + // Focus-specific handling + match self.focus { + FocusArea::Chat => self.handle_chat_key(key), + FocusArea::History => self.handle_history_key(key), + FocusArea::Sidebar => self.handle_sidebar_key(key), + } + } + + /// Handle overlay keyboard input + fn handle_overlay_key(&mut self, key: KeyEvent) -> ViewAction { + match self.overlay { + ActiveOverlay::CommandPalette => { + match self.command_popup.handle_key(key) { + CommandPopupAction::Execute(cmd) => { + self.close_overlay(); + return ViewAction::ExecuteCommand(cmd); + } + CommandPopupAction::Cancel => { + self.close_overlay(); + } + _ => {} + } + } + ActiveOverlay::Help => { + match self.help_viewer.handle_key(key) { + HelpAction::Close => { + self.close_overlay(); + } + _ => {} + } + } + ActiveOverlay::Approval => { + match self.approval_overlay.handle_key(key) { + ApprovalAction::Approve(id) | ApprovalAction::ApproveAll(id) => { + self.close_overlay(); + return ViewAction::Approve { + request_id: id, + approved: true, + }; + } + ApprovalAction::Reject(id) => { + self.close_overlay(); + return ViewAction::Approve { + request_id: id, + approved: false, + }; + } + _ => {} + } + } + ActiveOverlay::Pager => { + match self.pager_overlay.handle_key(key) { + PagerAction::Close => { + self.close_overlay(); + } + PagerAction::Copy(content) => { + return ViewAction::Copy(content); + } + _ => {} + } + } + ActiveOverlay::SessionPicker => { + match self.session_picker.handle_key(key) { + ResumePickerAction::Select(session_id) => { + self.close_overlay(); + return ViewAction::ResumeSession(session_id); + } + ResumePickerAction::Cancel => { + self.close_overlay(); + } + _ => {} + } + } + ActiveOverlay::Notifications => { + match self.notifications.panel.handle_key(key) { + NotificationPanelAction::Close => { + self.close_overlay(); + } + NotificationPanelAction::Dismiss(id) => { + self.notifications.panel.dismiss(&id); + } + NotificationPanelAction::DismissAll => { + self.notifications.panel.dismiss_all(); + } + NotificationPanelAction::MarkAllRead => { + self.notifications.panel.mark_all_read(); + } + _ => {} + } + } + ActiveOverlay::Alert => { + if let Some(ref mut alert) = self.notifications.alert { + match alert.handle_key(key) { + AlertAction::ButtonPressed(id) => { + self.close_overlay(); + return ViewAction::ExecuteCommand(id); + } + AlertAction::Cancelled => { + self.close_overlay(); + } + _ => {} + } + } + } + ActiveOverlay::None => {} + } + ViewAction::None + } + + /// Handle chat input keys + fn handle_chat_key(&mut self, key: KeyEvent) -> ViewAction { + match self.chat.handle_key(key) { + ComposerAction::Submit => { + let message = self.chat.get_input(); + if !message.is_empty() { + self.chat.clear(); + return ViewAction::SendMessage(message); + } + } + ComposerAction::Cancel => { + self.chat.clear(); + } + _ => {} + } + + // Tab to switch focus + if key.code == KeyCode::Tab && key.modifiers.is_empty() { + self.focus = FocusArea::History; + } + + ViewAction::None + } + + /// Handle history navigation keys + fn handle_history_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.history_scroll = self.history_scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if self.history_scroll < self.max_scroll { + self.history_scroll += 1; + } + } + KeyCode::PageUp => { + self.history_scroll = self.history_scroll.saturating_sub(10); + } + KeyCode::PageDown => { + self.history_scroll = (self.history_scroll + 10).min(self.max_scroll); + } + KeyCode::Home | KeyCode::Char('g') => { + self.history_scroll = 0; + } + KeyCode::End | KeyCode::Char('G') => { + self.history_scroll = self.max_scroll; + } + KeyCode::Tab | KeyCode::Char('i') => { + self.focus = FocusArea::Chat; + } + _ => {} + } + ViewAction::None + } + + /// Handle sidebar navigation keys + fn handle_sidebar_key(&mut self, key: KeyEvent) -> ViewAction { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if self.sidebar_selected > 0 { + self.sidebar_selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.sidebar_selected < self.sidebar_items.len().saturating_sub(1) { + self.sidebar_selected += 1; + } + } + KeyCode::Enter => { + if let Some(item) = self.sidebar_items.get(self.sidebar_selected) { + return ViewAction::OpenFile(item.clone()); + } + } + KeyCode::Tab | KeyCode::Char('l') => { + self.focus = FocusArea::Chat; + } + _ => {} + } + ViewAction::None + } + + /// Tick for animations + pub fn tick(&mut self) { + self.notifications.cleanup(); + } + + /// Render the main view + pub fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + + // Calculate main layout + let main_chunks: Vec = if self.layout.show_sidebar { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(self.layout.sidebar_width), + Constraint::Percentage(100 - self.layout.sidebar_width), + ]) + .split(area) + .to_vec() + } else { + vec![area] + }; + + // Render sidebar if visible + if self.layout.show_sidebar && main_chunks.len() > 1 { + self.render_sidebar(frame, main_chunks[0]); + } + + // Main content area + let content_area = if self.layout.show_sidebar && main_chunks.len() > 1 { + main_chunks[1] + } else { + main_chunks[0] + }; + + // Vertical layout for content + let mut constraints = vec![]; + if self.layout.show_breadcrumb { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Min(self.layout.min_history_height)); + constraints.push(Constraint::Length(self.layout.input_height)); + if self.layout.show_status_bar { + constraints.push(Constraint::Length(1)); + } + + let content_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(content_area); + + let mut chunk_idx = 0; + + // Render breadcrumb + if self.layout.show_breadcrumb { + self.render_breadcrumb(frame, content_chunks[chunk_idx]); + chunk_idx += 1; + } + + // Render history + self.render_history(frame, content_chunks[chunk_idx]); + chunk_idx += 1; + + // Render chat input + self.render_chat_input(frame, content_chunks[chunk_idx]); + chunk_idx += 1; + + // Render status bar + if self.layout.show_status_bar { + self.render_status_bar(frame, content_chunks[chunk_idx]); + } + + // Render overlays + self.render_overlay(frame, area); + } + + /// Render sidebar + fn render_sidebar(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(" Files ") + .title_style(Style::default().fg(colors::CYAN).bold()) + .borders(Borders::ALL) + .border_style(if self.focus == FocusArea::Sidebar { + Style::default().fg(colors::CYAN) + } else { + Style::default().fg(colors::BORDER) + }); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Render sidebar items + let items: Vec = self.sidebar_items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == self.sidebar_selected { + Style::default().fg(colors::CYAN).bold() + } else { + Style::default().fg(colors::FG) + }; + Line::from(Span::styled(item.as_str(), style)) + }) + .collect(); + + let paragraph = Paragraph::new(items); + frame.render_widget(paragraph, inner); + } + + /// Render breadcrumb + fn render_breadcrumb(&self, frame: &mut Frame, area: Rect) { + let breadcrumb = Breadcrumb::new() + .push(self.cwd.clone()) + .push(self.session_name.clone()); + breadcrumb.render(frame, area); + } + + /// Render message history + fn render_history(&mut self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(if self.focus == FocusArea::History { + Style::default().fg(colors::CYAN) + } else { + Style::default().fg(colors::BORDER) + }); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if self.history.is_empty() { + // Empty state + let centered = Rect { + y: inner.y + inner.height / 2, + height: 1, + ..inner + }; + + if self.mode == AppMode::Loading { + // Render spinner when loading + self.spinner.render(frame, centered); + } else { + let paragraph = Paragraph::new("Start a conversation...") + .style(Style::default().fg(colors::COMMENT)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, centered); + } + return; + } + + // Render history items + let mut lines: Vec = Vec::new(); + for cell in &self.history { + let rendered = cell.render(inner.width); + lines.extend(rendered); + lines.push(Line::from("")); // Spacing + } + + // Calculate scroll + let total_lines = lines.len(); + let visible_lines = inner.height as usize; + self.max_scroll = total_lines.saturating_sub(visible_lines); + + // Apply scroll + let start = self.history_scroll.min(self.max_scroll); + let visible: Vec = lines + .into_iter() + .skip(start) + .take(visible_lines) + .collect(); + + let paragraph = Paragraph::new(visible); + frame.render_widget(paragraph, inner); + + // Scrollbar + if total_lines > visible_lines { + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + let mut scrollbar_state = ScrollbarState::new(total_lines) + .position(start); + frame.render_stateful_widget( + scrollbar, + area, + &mut scrollbar_state, + ); + } + } + + /// Render chat input + fn render_chat_input(&mut self, frame: &mut Frame, area: Rect) { + let is_focused = self.focus == FocusArea::Chat; + + // Update focused state for the composer + self.chat.set_focused(is_focused && self.mode == AppMode::Normal); + + // Use ChatComposer's built-in render which handles cursor display + self.chat.render(frame, area); + } + + /// Render status bar + fn render_status_bar(&self, frame: &mut Frame, area: Rect) { + let mut status_bar = StatusBar::new() + .left( + StatusItem::new(self.model_name.clone()) + .style(Style::default().fg(colors::CYAN)), + ); + + // Token count + if self.tokens_used > 0 { + status_bar = status_bar.left( + StatusItem::new(format!("{}T", self.tokens_used)) + .style(Style::default().fg(colors::COMMENT)), + ); + } + + // Notification count + let unread = self.notifications.unread_count(); + if unread > 0 { + status_bar = status_bar.left( + StatusItem::new(format!("{}N", unread)) + .style(Style::default().fg(colors::YELLOW)), + ); + } + + // Mode indicator + let mode_str = match self.mode { + AppMode::Normal => "NORMAL", + AppMode::Streaming => "STREAM", + AppMode::WaitingApproval => "APPROVAL", + AppMode::Loading => "LOADING", + }; + status_bar = status_bar.right( + StatusItem::new(mode_str) + .style(Style::default().fg(match self.mode { + AppMode::Normal => colors::GREEN, + AppMode::Streaming => colors::YELLOW, + AppMode::WaitingApproval => colors::ORANGE, + AppMode::Loading => colors::CYAN, + })), + ); + + status_bar.render(frame, area); + } + + /// Render active overlay + fn render_overlay(&mut self, frame: &mut Frame, area: Rect) { + match self.overlay { + ActiveOverlay::CommandPalette => { + let popup_area = centered_rect(60, 50, area); + self.command_popup.render(frame, popup_area); + } + ActiveOverlay::Help => { + let popup_area = centered_rect(80, 80, area); + self.help_viewer.render(frame, popup_area); + } + ActiveOverlay::Approval => { + let popup_area = centered_rect(60, 40, area); + self.approval_overlay.render(frame, popup_area); + } + ActiveOverlay::Pager => { + let popup_area = centered_rect(90, 90, area); + self.pager_overlay.render(frame, popup_area); + } + ActiveOverlay::SessionPicker => { + let popup_area = centered_rect(70, 60, area); + self.session_picker.render(frame, popup_area); + } + ActiveOverlay::Notifications => { + let popup_area = centered_rect(60, 70, area); + self.notifications.panel.render(popup_area, frame.buffer_mut()); + } + ActiveOverlay::Alert => { + if let Some(ref alert) = self.notifications.alert { + alert.render(area, frame.buffer_mut()); + } + } + ActiveOverlay::None => {} + } + } +} + +/// Helper to create a centered rectangle +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_width = area.width * percent_x / 100; + let popup_height = area.height * percent_y / 100; + + Rect { + x: area.x + (area.width - popup_width) / 2, + y: area.y + (area.height - popup_height) / 2, + width: popup_width, + height: popup_height, + } +} + +/// Builder for creating views with custom configuration +pub struct ViewBuilder { + view: MainView, +} + +impl ViewBuilder { + /// Create a new view builder + pub fn new() -> Self { + Self { + view: MainView::new(), + } + } + + /// Set session name + pub fn session(mut self, name: impl Into) -> Self { + self.view.session_name = name.into(); + self + } + + /// Set model name + pub fn model(mut self, model: impl Into) -> Self { + self.view.model_name = model.into(); + self + } + + /// Enable sidebar + pub fn with_sidebar(mut self, items: Vec) -> Self { + self.view.layout.show_sidebar = true; + self.view.sidebar_items = items; + self + } + + /// Set sidebar width + pub fn sidebar_width(mut self, width: u16) -> Self { + self.view.layout.sidebar_width = width; + self + } + + /// Disable status bar + pub fn without_status_bar(mut self) -> Self { + self.view.layout.show_status_bar = false; + self + } + + /// Disable breadcrumb + pub fn without_breadcrumb(mut self) -> Self { + self.view.layout.show_breadcrumb = false; + self + } + + /// Set input height + pub fn input_height(mut self, height: u16) -> Self { + self.view.layout.input_height = height; + self + } + + /// Build the view + pub fn build(self) -> MainView { + self.view + } +} + +impl Default for ViewBuilder { + fn default() -> Self { + Self::new() + } +}