//! 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. //! The division pipeline mode adds a visual pipeline flow with arrows. 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; /// 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, } } /// Detect if the current workflow looks like a company pipeline. fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -> bool { if agents.is_empty() { return false; } let division_keywords = ["Strategy", "Engineering", "Quality", "Security", "Documentation"]; agents.iter().any(|a| { division_keywords.iter().any(|k| a.name.contains(k)) }) } /// 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 is_company = is_company_pipeline(&state.workflow_engine.agents); let title = if is_company { Span::styled(" 🏢 Pipeline ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)) } else { 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(); if is_company { // Division pipeline overview let agents = &state.workflow_engine.agents; let mut pipe_spans: Vec = Vec::new(); for (i, agent) in agents.iter().enumerate() { if i > 0 { pipe_spans.push(Span::styled( " ", Style::default().fg(Theme::TEXT_DIM), )); } let icon = state_icon(agent.status.state); let color = state_color(agent.status.state); let modif = match agent.status.state { AgentState::Idle => Modifier::empty(), _ => Modifier::BOLD, }; pipe_spans.push(Span::styled( format!("{} {} ", icon, agent.name.chars().take(10).collect::()), Style::default().fg(color).add_modifier(modif), )); if i < agents.len().saturating_sub(1) { pipe_spans.push(Span::styled( "→", Style::default().fg(Theme::TEXT_DIM), )); } } header_lines.push(Line::from(pipe_spans)); 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), ), ])); } else { 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)), ])); } // Card separator card_lines.push(Line::from(Span::raw(""))); } 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( " Complex tasks auto-delegate to the company pipeline.", Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC), ))); lines } use ratatui::style::Color;