//! Todo pane — renders `TodoItem`s from `xai-grok-tools` in a `ListPane`. //! //! Wraps the canonical `TodoItem` type with a `ListItem` implementation //! that provides status-icon prefixes and styled content. use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use xai_grok_shell::tools::{TodoItem, TodoStatus}; use super::list_pane::ListItem; // --------------------------------------------------------------------------- // TodoPaneStyle — per-status colors // --------------------------------------------------------------------------- /// Visual style for each todo status. #[derive(Debug, Clone, Copy)] pub struct TodoStatusStyle { /// Color for the status icon. pub icon_fg: Color, /// Style for the content text. pub text_style: Style, } /// Full style configuration for the todo pane. #[derive(Debug, Clone, Copy)] pub struct TodoPaneStyle { pub pending: TodoStatusStyle, pub in_progress: TodoStatusStyle, pub completed: TodoStatusStyle, pub cancelled: TodoStatusStyle, } impl Default for TodoPaneStyle { fn default() -> Self { // Sourced from theme to ensure colors are quantized for terminal compat. let theme = crate::theme::Theme::current(); Self { pending: TodoStatusStyle { icon_fg: theme.text_primary, text_style: Style::default().fg(theme.text_primary), }, in_progress: TodoStatusStyle { icon_fg: theme.warning, text_style: Style::default() .fg(theme.text_primary) .add_modifier(Modifier::BOLD), }, completed: TodoStatusStyle { icon_fg: theme.accent_success, text_style: Style::default().fg(theme.gray_bright), }, cancelled: TodoStatusStyle { icon_fg: theme.accent_error, text_style: Style::default() .fg(theme.gray_bright) .add_modifier(Modifier::CROSSED_OUT), }, } } } // --------------------------------------------------------------------------- // TodoListEntry — ListItem wrapper around TodoItem // --------------------------------------------------------------------------- /// A `TodoItem` wrapped for display in a `ListPane`. /// /// Caches the styled `Line` for `content()` and generates a status-icon /// `prefix()` per frame. #[derive(Debug, Clone)] pub struct TodoListEntry { /// Unique ID (index in the todo list, or a stable ID from the model). pub id: u64, /// The canonical todo item. pub item: TodoItem, /// Cached styled content line. styled: Line<'static>, /// The style to use for this entry's status. status_style: TodoStatusStyle, } impl TodoListEntry { /// Create a new entry from a `TodoItem`. pub fn new(id: u64, item: TodoItem, style: &TodoPaneStyle) -> Self { let status_style = match item.status { TodoStatus::Pending => style.pending, TodoStatus::InProgress => style.in_progress, TodoStatus::Completed => style.completed, TodoStatus::Cancelled => style.cancelled, }; let styled = Line::from(Span::styled(item.content.clone(), status_style.text_style)); Self { id, item, styled, status_style, } } /// Status icon for the current status. fn icon(&self) -> &'static str { match self.item.status { TodoStatus::Pending => "□", TodoStatus::InProgress => "▶", TodoStatus::Completed => crate::glyphs::check_mark(), TodoStatus::Cancelled => crate::glyphs::ballot_x(), } } } impl ListItem for TodoListEntry { fn content(&self) -> &Line<'_> { &self.styled } fn prefix(&self) -> Option> { let icon = self.icon(); Some(Line::from(vec![ Span::styled(icon, Style::default().fg(self.status_style.icon_fg)), Span::raw(" "), ])) } fn stable_id(&self) -> u64 { self.id } fn search_text(&self) -> &str { &self.item.content } } // --------------------------------------------------------------------------- // TodoPane — self-contained pane owning items, state, and rendering // --------------------------------------------------------------------------- use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, MouseEventKind}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::StatefulWidget; use crate::appearance::LayoutConfig; use crate::theme::ThemeKind; use super::list_pane::{ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode}; use super::overlay::OverlayState; // --------------------------------------------------------------------------- // TodoCounts — aggregate status counts for the badge // --------------------------------------------------------------------------- /// Counts of todo items by status. /// /// Used by the status bar badge to show plan progress at a glance. /// Counts ALL items regardless of `show_done` filter. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct TodoCounts { pub in_progress: usize, pub pending: usize, pub completed: usize, pub cancelled: usize, } impl TodoCounts { /// Total number of items across all statuses. pub fn total(&self) -> usize { self.in_progress + self.pending + self.completed + self.cancelled } /// Tasks that count toward completion progress — every status except /// cancelled. Used as the denominator of the status-bar `done/total` /// badge so cancelled tasks don't keep it from reaching `N/N`. pub fn total_excluding_cancelled(&self) -> usize { self.in_progress + self.pending + self.completed } } fn empty_placeholder_message(todos_empty: bool, counts: TodoCounts) -> String { if todos_empty { return "No todo items.".into(); } match (counts.completed, counts.cancelled) { (_, 0) => "All done.".into(), (0, c) => format!("{c} cancelled."), (d, c) => format!("{d} done. {c} cancelled."), } } /// Duration for the badge flash animation on count changes. const BADGE_FLASH_DURATION: Duration = Duration::from_millis(1200); /// Absolute maximum height (in lines) the todo pane will request. const MAX_TODO_HEIGHT: u16 = 10; /// Maximum fraction of total view height the todo pane may occupy. const MAX_TODO_FRACTION: f32 = 0.15; /// Self-contained todo pane component. /// /// Owns the raw `TodoItem` data, the filtered `TodoListEntry` cache, /// `ListPaneState`, and style config. `AgentView` holds a single /// `TodoPane` and delegates input/render to it. pub struct TodoPane { /// Raw todo items from ACP Plan updates. todos: Vec, /// Filtered + styled entries for `ListPane` rendering. /// Rebuilt from `todos` at the start of each `render()` call. entries: Vec, /// List pane state (scroll, selection, search, layout cache). pub list_state: ListPaneState, /// Per-status visual style for icons and text. style: TodoPaneStyle, /// Visual style for the list pane framework (selection bg, etc.). list_style: ListPaneStyle, /// Whether to show completed/cancelled items. show_done: bool, /// Shared visibility/focus state. pub overlay: OverlayState, /// Previous counts snapshot for flash-on-change detection. prev_counts: TodoCounts, /// When the badge flash animation expires (500ms after a count change). badge_flash_until: Option, /// Last theme kind seen — used to detect theme switches and restyle. last_theme: ThemeKind, } impl Default for TodoPane { fn default() -> Self { Self::new() } } impl TodoPane { /// Create a new empty todo pane. pub fn new() -> Self { let config = ListPaneConfig { follow_enabled: false, wrap_toggle_enabled: false, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: false, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::NoWrap, false, config); list_state.set_clipboard_provider(Box::new(crate::clipboard::SystemClipboard)); Self { todos: Vec::new(), entries: Vec::new(), list_state, style: TodoPaneStyle::default(), list_style: ListPaneStyle::default(), show_done: true, // Starts hidden — auto-shows when items arrive via update_todos. overlay: OverlayState::hidden(), prev_counts: TodoCounts::default(), badge_flash_until: None, last_theme: crate::theme::Theme::current_kind(), } } // -- Data management ----------------------------------------------------- /// Read-only access to the current todo items. pub fn todos(&self) -> &[TodoItem] { &self.todos } /// Replace all todo items (called from ACP Plan handler). /// /// Does NOT auto-show the todo pane — the badge in the status bar is /// the primary indicator. Users toggle the pane with Ctrl-T or by /// clicking the badge. /// /// Triggers a badge flash when counts change (including first arrival). pub fn update_todos(&mut self, items: Vec) { let new_counts = Self::compute_counts(&items); if new_counts != self.prev_counts { self.badge_flash_until = Some(Instant::now() + BADGE_FLASH_DURATION); } self.prev_counts = new_counts; self.todos = items; } /// Compute status counts from a list of items. fn compute_counts(items: &[TodoItem]) -> TodoCounts { let mut c = TodoCounts::default(); for item in items { match item.status { TodoStatus::InProgress => c.in_progress += 1, TodoStatus::Pending => c.pending += 1, TodoStatus::Completed => c.completed += 1, TodoStatus::Cancelled => c.cancelled += 1, } } c } /// Current status counts (across ALL items, ignoring `show_done`). pub fn counts(&self) -> TodoCounts { self.prev_counts } /// Whether the badge flash animation is currently active. pub fn badge_flash_active(&self) -> bool { self.badge_flash_until.is_some_and(|t| Instant::now() < t) } /// Whether the badge needs animation ticks (flash expiry). pub fn badge_needs_tick(&self) -> bool { self.badge_flash_until.is_some() } /// Advance badge flash timer. Returns `true` if a redraw is needed /// (flash just expired). pub fn badge_tick(&mut self) -> bool { if let Some(t) = self.badge_flash_until && Instant::now() >= t { self.badge_flash_until = None; return true; } false } /// Test-only: backdate an armed badge flash so it reads as expired, letting /// a single `badge_tick()` clear it deterministically (no 1200ms sleep). #[cfg(test)] pub(crate) fn expire_badge_flash_for_test(&mut self) { if self.badge_flash_until.is_some() { self.badge_flash_until = Some(Instant::now() - Duration::from_millis(1)); } } /// Whether the pane should be visible in the layout. /// /// Visible when overlay is shown. When empty, shows a placeholder /// message instead of the list pane. pub fn is_visible(&self) -> bool { self.overlay.visible } /// Called after overlay state changes that hide the pane. /// /// Closes the input bar if it's actively open (mid-typing), but /// preserves any accepted search/filter so it persists across /// show/hide cycles. pub fn on_state_change(&mut self) { if !self.overlay.visible { self.list_state.close_input_bar(); } } /// Whether completed/cancelled items are shown. pub fn show_done(&self) -> bool { self.show_done } /// Toggle visibility of completed/cancelled items. pub fn toggle_show_done(&mut self) { self.show_done = !self.show_done; } /// Desired height in lines for layout computation. /// /// Returns 0 when hidden (overlay not visible or no items). /// When visible but empty, returns 2 (for placeholder message). /// Otherwise: `min(10, 15% of view_height)` but at least 1. pub fn desired_height(&self, view_height: u16) -> u16 { if !self.overlay.visible { return 0; } let count = self.visible_count(); if count == 0 { return 1; } let fraction_cap = (view_height as f32 * MAX_TODO_FRACTION).floor() as u16; let max = MAX_TODO_HEIGHT.min(fraction_cap).max(1); (count as u16).min(max).max(1) } /// Number of items that pass the `show_done` filter. fn visible_count(&self) -> usize { if self.show_done { self.todos.len() } else { self.todos .iter() .filter(|t| !matches!(t.status, TodoStatus::Completed | TodoStatus::Cancelled)) .count() } } /// Rebuild `entries` from `todos`, filtered by `show_done`. /// /// Items preserve their original order from the agent (no status-based /// reordering). IDs are based on the original index in `todos` so that /// `ListPaneState` can maintain selection across rebuilds. fn rebuild_entries(&mut self) { self.entries.clear(); for (idx, item) in self.todos.iter().enumerate() { if !self.show_done && matches!(item.status, TodoStatus::Completed | TodoStatus::Cancelled) { continue; } self.entries .push(TodoListEntry::new(idx as u64, item.clone(), &self.style)); } } // -- Input handling ------------------------------------------------------ /// Handle a key event when the todo pane is focused. /// /// Returns `true` if the event was consumed. pub fn handle_key(&mut self, key: &KeyEvent) -> bool { // 'h' toggles show_done (only when not typing in search/filter bar). if key.code == KeyCode::Char('h') && self.list_state.input_mode().is_none() { self.toggle_show_done(); self.rebuild_entries(); return true; } // Don't route to ListPaneState when empty. if self.entries.is_empty() { return false; } self.list_state.handle_key_event(key, &self.entries) } pub fn handle_paste(&mut self, text: &str) -> bool { self.list_state.handle_paste(text, &self.entries) } /// Handle a mouse scroll event over the todo pane area. /// /// Caps scroll speed for small viewports — the app-level scroll /// accumulator can produce large deltas (3-5 lines) which would /// jump past most items in a 4-row pane. pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) { let max = match self.list_state.viewport_height() { 0..=5 => 1, 6..=10 => 2, _ => lines.unsigned_abs() as i32, }; let capped = lines.signum() * lines.abs().min(max); self.list_state .handle_scroll_event(capped, col, row, &self.entries); } /// Handle a mouse click/drag event over the todo pane area. /// /// Returns `true` if the event was consumed. pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16, area: Rect) -> bool { self.list_state .handle_mouse_event(kind, col, row, area, &self.entries) } // -- Rendering ----------------------------------------------------------- /// Compute the inner content area with horizontal padding matching /// the scrollback's `HorizontalLayout` (accent + block_pad_left on /// the left, block_pad_right on the right). fn content_area(area: Rect, layout_cfg: &LayoutConfig) -> Rect { use crate::scrollback::layout::HorizontalLayout; let pad_left = HorizontalLayout::ACCENT + layout_cfg.block_pad_left; let pad_right = layout_cfg.block_pad_right; Rect { x: area.x + pad_left, y: area.y, width: area.width.saturating_sub(pad_left + pad_right), height: area.height, } } /// Render the todo pane into the given area. /// /// Rebuilds entries from `todos` each call (cheap — typically <20 items), /// runs layout, and renders the `ListPane` widget in a padded inner area /// matching the scrollback's horizontal layout. pub fn render( &mut self, area: Rect, buf: &mut Buffer, focused: bool, layout_cfg: &LayoutConfig, ) { // Detect theme switch and refresh styles before rebuilding entries. let current_theme = crate::theme::Theme::current_kind(); if current_theme != self.last_theme { self.last_theme = current_theme; self.style = TodoPaneStyle::default(); self.list_style = ListPaneStyle::default(); } self.rebuild_entries(); let inner = Self::content_area(area, layout_cfg); if self.entries.is_empty() { // Empty state: placeholder message in muted style. if inner.height > 0 && inner.width > 0 { let msg = empty_placeholder_message(self.todos.is_empty(), self.counts()); let theme = crate::theme::Theme::current(); let span = ratatui::text::Span::styled( msg, ratatui::style::Style::default().fg(theme.gray_bright), ); buf.set_span(inner.x, inner.y, &span, inner.width); } return; } self.list_state .prepare_layout(&self.entries, inner.width, inner.height); ListPane::new(&self.entries) .focused(focused) .style(self.list_style) .render(inner, buf, &mut self.list_state); } } #[cfg(test)] mod tests { use super::*; fn counts(completed: usize, cancelled: usize) -> TodoCounts { TodoCounts { completed, cancelled, ..TodoCounts::default() } } #[test] fn empty_todos_message() { assert_eq!( empty_placeholder_message(true, TodoCounts::default()), "No todo items." ); } #[test] fn all_completed_is_all_done() { assert_eq!(empty_placeholder_message(false, counts(3, 0)), "All done."); } #[test] fn mixed_done_and_cancelled_summarizes_counts() { assert_eq!( empty_placeholder_message(false, counts(5, 1)), "5 done. 1 cancelled." ); } #[test] fn only_cancelled() { assert_eq!( empty_placeholder_message(false, counts(0, 2)), "2 cancelled." ); } }