use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::Span,
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
use crate::tui::state::TuiState;
use crate::tui::theme::{TaskStatus, Theme, VerbColor};
use crate::tui::widgets::task_box::RenderMode;
const TASK_BOX_HEIGHT: u16 = 5;
pub struct TaskBoxFlow {
scroll_offset: u16,
auto_scroll: bool,
running_task_index: Option<usize>,
content_height: u16,
visible_height: u16,
pub render_mode: RenderMode,
}
impl TaskBoxFlow {
pub fn new() -> Self {
Self {
scroll_offset: 0,
auto_scroll: true,
running_task_index: None,
content_height: 0,
visible_height: 0,
render_mode: RenderMode::Expanded,
}
}
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
state: &TuiState,
theme: &Theme,
is_focused: bool,
) {
let style = if is_focused {
Style::default()
.fg(theme.border_focused)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.border_normal)
};
let focus_indicator = if is_focused { "●" } else { " " };
let scroll_indicator = if self.auto_scroll { "⟳" } else { "⏸" };
let title = format!(" {} Task Flow {} ", focus_indicator, scroll_indicator);
let block = Block::default()
.title(Span::styled(title, style))
.borders(Borders::ALL)
.border_style(style);
let inner = block.inner(area);
frame.render_widget(block, area);
self.visible_height = inner.height;
let task_count = state.task_order.len();
self.content_height = (task_count as u16) * TASK_BOX_HEIGHT;
self.running_task_index = None;
for (idx, task_id) in state.task_order.iter().enumerate() {
if let Some(task) = state.tasks.get(task_id) {
if task.status == TaskStatus::Running {
self.running_task_index = Some(idx);
break;
}
}
}
if self.auto_scroll {
if let Some(running_idx) = self.running_task_index {
let target_offset = (running_idx as u16) * TASK_BOX_HEIGHT;
let centered = target_offset.saturating_sub(self.visible_height / 2);
self.scroll_offset = centered.min(self.max_scroll());
}
}
self.render_task_boxes(frame, inner, state, theme);
if self.content_height > self.visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("▲"))
.end_symbol(Some("▼"));
let mut scrollbar_state = ScrollbarState::new(self.content_height as usize)
.position(self.scroll_offset as usize)
.viewport_content_length(self.visible_height as usize);
frame.render_stateful_widget(
scrollbar,
area, &mut scrollbar_state,
);
}
}
fn render_task_boxes(&self, frame: &mut Frame, area: Rect, state: &TuiState, theme: &Theme) {
let start_task = (self.scroll_offset / TASK_BOX_HEIGHT) as usize;
let visible_tasks = ((self.visible_height / TASK_BOX_HEIGHT) + 2) as usize;
for task_idx in start_task..start_task + visible_tasks {
if task_idx >= state.task_order.len() {
break;
}
let task_id = &state.task_order[task_idx];
if let Some(task) = state.tasks.get(task_id) {
let y_offset =
(task_idx as u16 * TASK_BOX_HEIGHT).saturating_sub(self.scroll_offset);
if y_offset >= area.height {
break;
}
let task_area = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
height: TASK_BOX_HEIGHT.min(area.height.saturating_sub(y_offset)),
};
let verb = Self::verb_from_task_type(task.task_type.as_deref());
self.render_task_box(frame, task_area, task_id, task, verb, theme);
}
}
}
fn render_task_box(
&self,
frame: &mut Frame,
area: Rect,
task_id: &str,
task: &crate::tui::state::TaskState,
verb: VerbColor,
theme: &Theme,
) {
let status_icon = match task.status {
TaskStatus::Queued => "○",
TaskStatus::Pending => "◦",
TaskStatus::Running => "◐",
TaskStatus::Success => "✓",
TaskStatus::Failed => "✗",
TaskStatus::Paused => "⏸",
TaskStatus::Skipped => "⊘",
};
let verb_icon = verb.icon();
let status_color = match task.status {
TaskStatus::Queued => theme.text_muted,
TaskStatus::Pending => theme.text_muted,
TaskStatus::Running => theme.status_running,
TaskStatus::Success => theme.status_success,
TaskStatus::Failed => theme.status_failed,
TaskStatus::Paused => theme.text_muted,
TaskStatus::Skipped => theme.text_muted,
};
let title = format!("{} {} │ {} │", verb_icon, task_id, status_icon);
let duration_str = task
.duration_ms
.map(|ms| format!("{}ms", ms))
.unwrap_or_else(|| "~?s".to_string());
let tokens_str = task
.tokens
.map(|t| format!("{} tok", t))
.unwrap_or_default();
let info_line = format!(" {} │ {}", duration_str, tokens_str);
let preview = task
.output
.as_ref()
.and_then(|o| serde_json::to_string(&**o).ok())
.map(|s| {
let truncated = if s.len() > 40 {
format!("{}...", crate::util::truncate_str(&s, 40))
} else {
s
};
format!(" {}", truncated)
})
.unwrap_or_default();
let block_style = if task.status == TaskStatus::Running {
Style::default()
.fg(theme.status_running)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.border_normal)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(block_style)
.title(Span::styled(
format!(" {} ", task_id),
Style::default().fg(status_color),
));
let content = format!("{}\n{}\n{}", title, info_line, preview);
let paragraph = Paragraph::new(content).block(block);
frame.render_widget(paragraph, area);
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.auto_scroll = false; self.scroll_up(1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
self.auto_scroll = false;
self.scroll_down(1);
true
}
KeyCode::PageUp => {
self.auto_scroll = false;
self.scroll_up(self.visible_height.saturating_sub(2));
true
}
KeyCode::PageDown => {
self.auto_scroll = false;
self.scroll_down(self.visible_height.saturating_sub(2));
true
}
KeyCode::Home | KeyCode::Char('g') => {
self.auto_scroll = false;
self.scroll_offset = 0;
true
}
KeyCode::End | KeyCode::Char('G') => {
self.auto_scroll = true;
self.scroll_offset = self.max_scroll();
true
}
KeyCode::Char('f') => {
self.auto_scroll = !self.auto_scroll;
true
}
_ => false,
}
}
fn scroll_up(&mut self, amount: u16) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
fn scroll_down(&mut self, amount: u16) {
self.scroll_offset = (self.scroll_offset + amount).min(self.max_scroll());
}
fn max_scroll(&self) -> u16 {
self.content_height.saturating_sub(self.visible_height)
}
fn verb_from_task_type(task_type: Option<&str>) -> VerbColor {
match task_type {
Some("infer") => VerbColor::Infer,
Some("exec") => VerbColor::Exec,
Some("fetch") => VerbColor::Fetch,
Some("invoke") => VerbColor::Invoke,
Some("agent") => VerbColor::Agent,
_ => VerbColor::Infer,
}
}
}
impl Default for TaskBoxFlow {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_flow_new() {
let flow = TaskBoxFlow::new();
assert!(flow.auto_scroll);
assert_eq!(flow.scroll_offset, 0);
}
#[test]
fn test_auto_scroll_pause_resume() {
let mut flow = TaskBoxFlow::new();
assert!(flow.auto_scroll);
let key = KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE);
flow.handle_key(key);
assert!(!flow.auto_scroll);
let key = KeyEvent::new(KeyCode::Char('G'), crossterm::event::KeyModifiers::NONE);
flow.handle_key(key);
assert!(flow.auto_scroll);
}
#[test]
fn test_scroll_bounds() {
let mut flow = TaskBoxFlow::new();
flow.content_height = 100;
flow.visible_height = 20;
flow.scroll_down(200);
assert_eq!(flow.scroll_offset, 80);
flow.scroll_up(200);
assert_eq!(flow.scroll_offset, 0);
}
}