//! Workflow status panel rendering — agent cards with state badges. //! //! Flow: `draw_workflow_panel` reads `state.workflow_engine` and renders a //! panel showing agent statuses, findings count, session counters, and //! usage hints. //! //! Design: agents are shown as compact cards with state-colored badges, //! including hive-mind nodes (named by their system-assigned designation, //! e.g. `"Node-0-1"`). use ratatui::layout::Rect; use ratatui::style::{Style, Modifier}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::theme::Theme; use crate::app::workflow::engine::{AgentState, WorkflowAgent}; /// Icons for agent states. fn state_icon(state: AgentState) -> &'static str { match state { AgentState::Idle => "○", AgentState::Running => "▶", AgentState::Completed => "✓", AgentState::Failed => "✗", } } fn state_label(state: AgentState) -> &'static str { match state { AgentState::Idle => "Idle", AgentState::Running => "Running", AgentState::Completed => "Done", AgentState::Failed => "Failed", } } fn state_color(state: AgentState) -> Color { match state { AgentState::Idle => Theme::TEXT_DIM, AgentState::Running => Theme::WARNING, AgentState::Completed => Theme::SUCCESS, AgentState::Failed => Theme::ERROR, } } /// Render the workflow status panel. #[allow(clippy::too_many_lines)] pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { use ratatui::layout::{Constraint, Direction, Layout}; let title = Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) .title(title); let inner = block.inner(area); frame.render_widget(block, area); // Split inner into header and body let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(4), ]) .split(inner); // ── Header area ────────────────────────────────────────────────────── let mut header_lines: Vec = Vec::new(); header_lines.push(Line::from(vec![ Span::styled("/workflow run ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)), Span::styled("", Style::default().fg(Theme::TEXT_DIM)), Span::styled(" · Esc to close", Style::default().fg(Theme::TEXT_DIM)), ])); header_lines.push(Line::from(vec![ Span::styled("Status: ", Style::default().fg(Theme::TEXT_DIM)), if state.turn_in_flight() { Span::styled("● Running", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)) } else { Span::styled("● Idle", Style::default().fg(Theme::SUCCESS)) }, Span::raw(" "), Span::styled( format!("Agents: {} | Findings: {}", state.workflow_engine.agents.len(), state.workflow_engine.findings.len(), ), Style::default().fg(Theme::TEXT_DIM), ), ])); let header = Paragraph::new(header_lines); frame.render_widget(header, chunks[0]); // ── Body: agent cards ──────────────────────────────────────────────── if state.workflow_engine.agents.is_empty() { let session_lines = build_session_lines(state); let placeholder = Paragraph::new(session_lines).wrap(Wrap { trim: false }); frame.render_widget(placeholder, chunks[1]); } else { let mut card_lines: Vec = Vec::new(); for agent in &state.workflow_engine.agents { let color = state_color(agent.status.state); let icon = state_icon(agent.status.state); let label = state_label(agent.status.state); let duration_str = match (agent.status.started_at, agent.status.completed_at) { (Some(s), Some(e)) => format!(" {}ms", e.saturating_sub(s)), (Some(_), None) => " (running)".to_string(), _ => String::new(), }; // Agent card header card_lines.push(Line::from(vec![ Span::styled( format!(" {icon} "), Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::styled( format!(" {}", agent.name), Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), ), Span::styled( format!(" [{label}]"), Style::default().fg(color), ), Span::styled( duration_str, Style::default().fg(Theme::TEXT_DIM), ), ])); // Agent details (progress / error) if let Some(ref err) = agent.status.error { card_lines.push(Line::from(vec![ Span::styled(" ⚠ ", Style::default().fg(Theme::ERROR)), Span::styled(err.clone(), Style::default().fg(Theme::ERROR)), ])); } else if let Some(ref prog) = agent.status.progress { card_lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(prog.clone(), Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)), ])); } } let list = Paragraph::new(card_lines); frame.render_widget(list, chunks[1]); } } /// Build compact session info for the placeholder view. fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec> { let mut lines: Vec> = Vec::new(); lines.push(Line::from(Span::styled( " No workflow running.", Style::default().fg(Theme::TEXT_DIM), ))); lines.push(Line::from(Span::raw(""))); if let Some(ref rt) = state.session_runtime { let tool_count = rt.tool_call_results.len(); let pending = rt.pending_tool_queue.len(); let bash_count = rt.bash_jobs.len(); let msg_count = rt.messages.len(); lines.push(Line::from(vec![ Span::styled(" Messages ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(msg_count.to_string(), Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)), ])); lines.push(Line::from(vec![ Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!(" {tool_count}"), Style::default().fg(Theme::SUCCESS)), ])); if pending > 0 { lines.push(Line::from(vec![ Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!(" {pending}"), Style::default().fg(Theme::WARNING)), ])); } if bash_count > 0 { lines.push(Line::from(vec![ Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)), Span::styled(format!(" {bash_count}"), Style::default().fg(Theme::WARNING)), ])); } } else { lines.push(Line::from(Span::styled( " (no active session)", Style::default().fg(Theme::TEXT_DIM), ))); } lines.push(Line::from(Span::raw(""))); lines.push(Line::from(Span::styled( " The Hive is dormant. Complex tasks will stir it.", Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), ))); lines } /// Render the compact Workflow widget for the persistent sidebar: one /// line per agent (icon + name), truncated to whatever fits with a /// trailing "+N more" hint pointing at `/workflow` for the full view. /// /// Flow: bordered `Block` titled "Workflow" → empty state if no agents → /// else `split_for_display` caps the list to the inner height (minus one /// row for the hint line, if needed) → one line per visible agent. pub fn draw_workflow_widget(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let block = Block::default() .title(Span::styled(" Workflow ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD))) .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)); let budget = (block.inner(area).height as usize).max(1); let agents = &state.workflow_engine.agents; let lines: Vec = if agents.is_empty() { vec![Line::from(Span::styled( " No workflow running.", Style::default().fg(Theme::TEXT_DIM), ))] } else { let show_hint = agents.len() > budget; let item_budget = if show_hint { budget.saturating_sub(1).max(1) } else { budget }; let (visible, hidden) = super::split_for_display(agents.as_slice(), item_budget); let mut lines: Vec = visible.iter().map(workflow_agent_line).collect(); if show_hint { lines.push(super::overflow_hint_line(hidden, "/workflow")); } lines }; let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, area); } /// One compact line for a single agent: state icon + name, state-colored. fn workflow_agent_line(agent: &WorkflowAgent) -> Line<'static> { let color = state_color(agent.status.state); let icon = state_icon(agent.status.state); Line::from(vec![ Span::styled(format!(" {icon} "), Style::default().fg(color).add_modifier(Modifier::BOLD)), Span::styled(agent.name.clone(), Style::default().fg(Theme::TEXT)), ]) } use ratatui::style::Color;