Changes: - Gate session-lifecycle heap steady state with a dhat soak - Unbreak merge lifecycle e2e after default model → grok-4.5 - Scan home-scope rules dirs at <root>/rules - Complete text-input paste and terminal parity - Gate project roles and personas - Use canonical editing in dialogs - Use canonical editing in search bars - Reject ambiguous MCP tool IDs - Harden Git operands for plugins - Simplify queue drain API - Pass RFC 9207 iss through MCP OAuth token exchange - Show leader roster when local agents map is empty - Use canonical editing in Persona views - Remove marketplace default-skills auto-install and purge old installs - Use canonical editing in extension forms - Add canonical dashboard text editing - Use canonical editing in settings - Add /summarize as a /recap alias - Restore previous agent when exiting dashboard - Use tool_choice auto for compaction - Settings toggle for snap-prompt-to-top on send - Update default models to grok-4.5 - Source login shell once for local bash (env + alias/function snapshot) - Template hardcoded param names in server-native tool descriptions - Fix System-Reminder XML tag injection in CLAUDE.md via agents_md - Fix remote workspace-server hardcoding LSP trust (repo code execution risk) - Clear orphaned tool-call updates at turn end - Suppress task wake after cancel - Send x-grok-client-identifier on direct API tool calls - Harden dashboard peek lease transitions - Host /btw side panel in live region (minimal mode) - Bound scroll presentation latency - Highlight multi-line constructs correctly in diffs and the file viewer - Block web_fetch non-public IPs; local opt-in is explicit-host only - Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness - Follow up clipboard delivery feedback - Use canonical editing in pickers - Route TextArea through canonical editor - Persistent "watching" status row; quieter turn markers - Gate sensitive edit targets - Expose agent registry counts and gate session churn on them - Default coding data sharing to opt-out until server preference applies - Wire chat attachment ids through gateway prompts - On auth refresh failure, issue retry - Forward preview provenance and computer lifecycle state - Document independent privacy controls and scope /privacy output - Strip SamplingError Display prefix on rate-limit UI copy - Stop dumping Cloudflare HTML into Retry failed - Disable in-place prompt edit (scroll jank on enter) - Strip forced ANSI color from gh pr view JSON - Plumb bash tool description onto ToolUsageCard wire
565 lines
19 KiB
Rust
565 lines
19 KiB
Rust
//! 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<Line<'_>> {
|
|
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<TodoItem>,
|
|
/// Filtered + styled entries for `ListPane` rendering.
|
|
/// Rebuilt from `todos` at the start of each `render()` call.
|
|
entries: Vec<TodoListEntry>,
|
|
/// 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<Instant>,
|
|
/// 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<TodoItem>) {
|
|
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."
|
|
);
|
|
}
|
|
}
|