Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
561
crates/codegen/xai-grok-pager/src/views/todo_pane.rs
Normal file
561
crates/codegen/xai-grok-pager/src/views/todo_pane.rs
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
//! 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)
|
||||
}
|
||||
|
||||
/// 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue