use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use crate::tui::state::{TaskState, TuiState};
use crate::tui::theme::{TaskStatus, Theme, VerbColor};
pub struct InfoPanel {
scroll_offset: u16,
selected_task_id: Option<String>,
}
impl InfoPanel {
pub fn new() -> Self {
Self {
scroll_offset: 0,
selected_task_id: None,
}
}
pub fn select_task(&mut self, task_id: Option<String>) {
if self.selected_task_id != task_id {
self.selected_task_id = task_id;
self.scroll_offset = 0; }
}
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 title = format!(" {} Info ", focus_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);
let task = self
.selected_task_id
.as_ref()
.and_then(|id| state.tasks.get(id));
match task {
Some(task) => self.render_task_details(frame, inner, task, theme),
None => self.render_empty(frame, inner, theme),
}
}
fn render_empty(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let text = Text::from(vec![
Line::from(""),
Line::from(Span::styled(
"No task selected",
Style::default().fg(theme.text_muted),
)),
Line::from(""),
Line::from(Span::styled(
"Select a task from the list",
Style::default().fg(theme.text_muted),
)),
]);
let paragraph = Paragraph::new(text);
frame.render_widget(paragraph, area);
}
fn render_task_details(&self, frame: &mut Frame, area: Rect, task: &TaskState, theme: &Theme) {
let mut lines: Vec<Line> = Vec::new();
let verb = Self::verb_from_task_type(task.task_type.as_deref());
let verb_icon = Self::verb_icon(verb);
let status_icon = Self::status_icon(task.status);
let status_color = Self::status_color(task.status, theme);
lines.push(Line::from(vec![
Span::styled("TASK: ", Style::default().fg(theme.text_muted)),
Span::styled(
&task.id,
Style::default()
.fg(theme.text_primary)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("TYPE: ", Style::default().fg(theme.text_muted)),
Span::styled(
format!("{} ", verb_icon),
Style::default().fg(theme.verb_color(verb)),
),
Span::styled(
task.task_type.as_deref().unwrap_or("unknown"),
Style::default().fg(theme.text_primary),
),
]));
lines.push(Line::from(vec![
Span::styled("STATUS: ", Style::default().fg(theme.text_muted)),
Span::styled(
format!("{} ", status_icon),
Style::default().fg(status_color),
),
Span::styled(
Self::status_text(task.status),
Style::default().fg(status_color),
),
]));
lines.push(Line::from(""));
if let Some(ref input) = task.input {
lines.push(Line::from(Span::styled(
"─── INPUT ─────────────────────",
Style::default().fg(theme.text_muted),
)));
let input_str =
serde_json::to_string_pretty(&**input).unwrap_or_else(|_| "...".to_string());
let input_lines: Vec<_> = input_str.lines().collect();
for line in input_lines.iter().take(5) {
lines.push(Line::from(Span::styled(
(*line).to_string(),
Style::default().fg(theme.text_secondary),
)));
}
if input_lines.len() > 5 {
lines.push(Line::from(Span::styled(
"...",
Style::default().fg(theme.text_muted),
)));
}
lines.push(Line::from(""));
}
if let Some(ref output) = task.output {
lines.push(Line::from(Span::styled(
"─── OUTPUT ────────────────────",
Style::default().fg(theme.text_muted),
)));
let output_str =
serde_json::to_string_pretty(&**output).unwrap_or_else(|_| "...".to_string());
let output_lines: Vec<_> = output_str.lines().collect();
for line in output_lines.iter().take(10) {
lines.push(Line::from(Span::styled(
(*line).to_string(),
Style::default().fg(theme.text_primary),
)));
}
if output_lines.len() > 10 {
lines.push(Line::from(Span::styled(
"...",
Style::default().fg(theme.text_muted),
)));
}
lines.push(Line::from(""));
} else if task.status == TaskStatus::Running {
lines.push(Line::from(Span::styled(
"─── OUTPUT ────────────────────",
Style::default().fg(theme.text_muted),
)));
lines.push(Line::from(Span::styled(
"[streaming...]",
Style::default()
.fg(theme.status_running)
.add_modifier(Modifier::ITALIC),
)));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"─── METRICS ───────────────────",
Style::default().fg(theme.text_muted),
)));
if let Some(duration_ms) = task.duration_ms {
let duration_str = if duration_ms >= 1000 {
format!("{:.1}s", duration_ms as f64 / 1000.0)
} else {
format!("{}ms", duration_ms)
};
lines.push(Line::from(vec![
Span::styled("Duration: ", Style::default().fg(theme.text_muted)),
Span::styled(duration_str, Style::default().fg(theme.text_primary)),
]));
}
if let Some(tokens) = task.tokens {
if tokens > 0 {
lines.push(Line::from(vec![
Span::styled("Tokens: ", Style::default().fg(theme.text_muted)),
Span::styled(
format!("{}", tokens),
Style::default().fg(theme.text_primary),
),
]));
}
}
if let Some(ref model) = task.model {
lines.push(Line::from(vec![
Span::styled("Model: ", Style::default().fg(theme.text_muted)),
Span::styled(model.clone(), Style::default().fg(theme.text_primary)),
]));
}
if task.status == TaskStatus::Failed {
if let Some(ref error) = task.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"─── ERROR ─────────────────────",
Style::default().fg(theme.status_failed),
)));
lines.push(Line::from(Span::styled(
error.clone(),
Style::default().fg(theme.status_failed),
)));
}
}
let text = Text::from(lines);
let paragraph = Paragraph::new(text)
.scroll((self.scroll_offset, 0))
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_offset = self.scroll_offset.saturating_add(1);
true
}
KeyCode::PageUp => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
true
}
KeyCode::PageDown => {
self.scroll_offset = self.scroll_offset.saturating_add(10);
true
}
KeyCode::Home | KeyCode::Char('g') => {
self.scroll_offset = 0;
true
}
_ => false,
}
}
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,
}
}
fn verb_icon(verb: VerbColor) -> &'static str {
verb.icon()
}
fn status_icon(status: TaskStatus) -> &'static str {
match status {
TaskStatus::Queued => "○",
TaskStatus::Pending => "◦",
TaskStatus::Running => "◐",
TaskStatus::Success => "✓",
TaskStatus::Failed => "✗",
TaskStatus::Paused => "⏸",
TaskStatus::Skipped => "⊘",
}
}
fn status_text(status: TaskStatus) -> &'static str {
match status {
TaskStatus::Queued => "Queued",
TaskStatus::Pending => "Pending",
TaskStatus::Running => "Running",
TaskStatus::Success => "Success",
TaskStatus::Failed => "Failed",
TaskStatus::Paused => "Paused",
TaskStatus::Skipped => "Skipped",
}
}
fn status_color(status: TaskStatus, theme: &Theme) -> ratatui::style::Color {
match 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,
}
}
}
impl Default for InfoPanel {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_info_panel_new() {
let panel = InfoPanel::new();
assert!(panel.selected_task_id.is_none());
assert_eq!(panel.scroll_offset, 0);
}
#[test]
fn test_select_task_resets_scroll() {
let mut panel = InfoPanel::new();
panel.scroll_offset = 10;
panel.select_task(Some("task1".to_string()));
assert_eq!(panel.scroll_offset, 0);
}
#[test]
fn test_scroll_navigation() {
let mut panel = InfoPanel::new();
let key = KeyEvent::new(KeyCode::Down, crossterm::event::KeyModifiers::NONE);
panel.handle_key(key);
assert_eq!(panel.scroll_offset, 1);
let key = KeyEvent::new(KeyCode::Up, crossterm::event::KeyModifiers::NONE);
panel.handle_key(key);
assert_eq!(panel.scroll_offset, 0);
panel.handle_key(key);
assert_eq!(panel.scroll_offset, 0);
}
}