feat: enhance agent HUD with animated sparkline and improved status display

- Add animated sparkline visualization to agent panel showing activity levels
- Implement dynamic sparkline height based on agent count (1-4 lines)
- Add color coding: border color for active, green for completed tasks
- Fix sparkline visibility during planning/preparing phase with better scaling
- Add space prefix to all agent HUD lines for consistent indentation
- Change "Preparing agents..." text to dim color for subtler appearance
- Simplify agent status display to show just model name and status
- Auto-hide agent HUD when tasks complete or fail
- Replace eprintln\! debug logging with proper tracing macros
- Add comprehensive debug logging for sparkline rendering

The sparkline now provides visual feedback during all agent phases:
- Planning: 1 line with high variation (±50%) for visibility
- Running: 2-4 lines based on agent count with standard variation (±30%)
- Activity levels scale from low (planning) to high (multiple agents)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-12 22:38:46 +10:00
co-authored by Claude
parent 0387e602ad
commit 0b631c7ff7
7 changed files with 448 additions and 181 deletions
+52 -1
View File
@@ -4,12 +4,13 @@ use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::process::Command;
use tokio::sync::RwLock;
use tokio::sync::{RwLock, mpsc};
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::config_types::AgentConfig;
use crate::openai_tools::{JsonSchema, OpenAiTool, ResponsesApiTool};
use crate::protocol::{Event, EventMsg, AgentStatusUpdateEvent, AgentInfo};
// Agent status enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -55,6 +56,7 @@ lazy_static::lazy_static! {
pub struct AgentManager {
agents: HashMap<String, Agent>,
handles: HashMap<String, JoinHandle<()>>,
event_sender: Option<mpsc::UnboundedSender<Event>>,
}
impl AgentManager {
@@ -62,9 +64,49 @@ impl AgentManager {
Self {
agents: HashMap::new(),
handles: HashMap::new(),
event_sender: None,
}
}
pub fn set_event_sender(&mut self, sender: mpsc::UnboundedSender<Event>) {
self.event_sender = Some(sender);
}
async fn send_agent_status_update(&self) {
if let Some(ref sender) = self.event_sender {
let agents: Vec<AgentInfo> = self.agents.values()
.map(|agent| {
// Just show the model name - status provides the useful info
let name = agent.model.clone();
AgentInfo {
id: agent.id.clone(),
name,
status: format!("{:?}", agent.status).to_lowercase(),
model: Some(agent.model.clone()),
}
})
.collect();
// Get context and task from the first agent (they're all the same)
let (context, task) = self.agents.values().next()
.map(|agent| (agent.context.clone(), agent.output_goal.clone()))
.unwrap_or((None, None));
let event = Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents,
context,
task
}),
};
let _ = sender.send(event);
}
}
pub async fn create_agent(
&mut self,
model: String,
@@ -148,6 +190,9 @@ impl AgentManager {
self.agents.insert(agent_id.clone(), agent.clone());
// Send initial status update
self.send_agent_status_update().await;
// Spawn async agent
let agent_id_clone = agent_id.clone();
let handle = tokio::spawn(async move {
@@ -245,6 +290,8 @@ impl AgentManager {
) {
agent.completed_at = Some(Utc::now());
}
// Send status update event
self.send_agent_status_update().await;
}
}
@@ -261,6 +308,8 @@ impl AgentManager {
}
}
agent.completed_at = Some(Utc::now());
// Send status update event
self.send_agent_status_update().await;
}
}
@@ -269,6 +318,8 @@ impl AgentManager {
agent
.progress
.push(format!("{}: {}", Utc::now().format("%H:%M:%S"), message));
// Send updated agent status with the latest progress
self.send_agent_status_update().await;
}
}
+21 -1
View File
@@ -825,6 +825,22 @@ async fn submission_loop(
tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
) {
// Initialize agent manager with event sender
{
let mut manager = AGENT_MANAGER.write().await;
let (agent_tx, mut agent_rx) = tokio::sync::mpsc::unbounded_channel();
manager.set_event_sender(agent_tx);
drop(manager);
// Forward agent events to the main event channel
let tx_event_clone = tx_event.clone();
tokio::spawn(async move {
while let Some(event) = agent_rx.recv().await {
let _ = tx_event_clone.send(event).await;
}
});
}
let mut sess: Option<Arc<Session>> = None;
// shorthand - send an event when there is no active session
let send_no_session_event = |sub_id: String| async {
@@ -2986,7 +3002,11 @@ async fn send_agent_status_update(sess: &Session) {
let event = Event {
id: "agent_status".to_string(),
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { agents }),
msg: EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
agents,
context: None,
task: None
}),
};
// Send event asynchronously
+4
View File
@@ -724,6 +724,10 @@ pub struct BrowserScreenshotUpdateEvent {
pub struct AgentStatusUpdateEvent {
/// List of currently active agents
pub agents: Vec<AgentInfo>,
/// Shared context for all agents (if available)
pub context: Option<String>,
/// Shared task/output goal for all agents (if available)
pub task: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
+5
View File
@@ -480,6 +480,11 @@ impl App<'_> {
}));
}
},
AppEvent::PrepareAgents => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.prepare_agents();
}
}
AppEvent::UpdateReasoningEffort(new_effort) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_reasoning_effort(new_effort);
+3
View File
@@ -38,6 +38,9 @@ pub(crate) enum AppEvent {
/// layer so it can be handled centrally. Includes the full command text.
DispatchCommand(SlashCommand, String),
/// Signal that agents are about to start (triggered when /plan, /solve, /code commands are entered)
PrepareAgents,
/// Update the reasoning effort level
UpdateReasoningEffort(ReasoningEffort),
@@ -309,6 +309,11 @@ impl ChatComposer {
// Record the exact slash command that was typed
self.history.record_local_submission(&command_text);
// Check if this is a prompt-expanding command that will trigger agents
if cmd.is_prompt_expanding() {
self.app_event_tx.send(AppEvent::PrepareAgents);
}
// Send command to the app layer with full text.
self.app_event_tx
.send(AppEvent::DispatchCommand(*cmd, command_text.clone()));
@@ -600,6 +605,15 @@ impl ChatComposer {
if text.is_empty() {
(InputResult::None, true)
} else {
// Check if this is a prompt-expanding command that will trigger agents
let trimmed = original_text.trim();
if trimmed.starts_with("/plan ")
|| trimmed.starts_with("/solve ")
|| trimmed.starts_with("/code ")
{
self.app_event_tx.send(AppEvent::PrepareAgents);
}
self.history.record_local_submission(&original_text);
(InputResult::Submitted(text), true)
}
+349 -179
View File
@@ -114,6 +114,13 @@ pub(crate) struct ChatWidget<'a> {
active_agents: Vec<AgentInfo>,
agents_ready_to_start: bool,
last_agent_prompt: Option<String>,
agent_context: Option<String>,
agent_task: Option<String>,
overall_task_status: String,
// Sparkline data for showing agent activity (using RefCell for interior mutability)
// Each tuple is (value, is_completed) where is_completed indicates if any agent was complete at that time
sparkline_data: std::cell::RefCell<Vec<(u64, bool)>>,
last_sparkline_update: std::cell::RefCell<std::time::Instant>,
}
struct UserMessage {
@@ -443,6 +450,11 @@ impl ChatWidget<'_> {
active_agents: Vec::new(),
agents_ready_to_start: false,
last_agent_prompt: None,
agent_context: None,
agent_task: None,
overall_task_status: "preparing".to_string(),
sparkline_data: std::cell::RefCell::new(Vec::new()),
last_sparkline_update: std::cell::RefCell::new(std::time::Instant::now()),
}
}
@@ -600,15 +612,13 @@ impl ChatWidget<'_> {
let original_text = text.clone();
let mut actual_text = text;
// Check for multi-agent commands first (before processing slash commands)
// Save the prompt if it's a multi-agent command
let original_trimmed = original_text.trim();
if original_trimmed.starts_with("/plan ")
|| original_trimmed.starts_with("/solve ")
|| original_trimmed.starts_with("/code ")
{
self.agents_ready_to_start = true;
self.last_agent_prompt = Some(original_text.clone());
self.request_redraw();
}
// Process slash commands and expand them if needed
@@ -1124,7 +1134,7 @@ impl ChatWidget<'_> {
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
info!("BackgroundEvent: {message}");
}
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { agents }) => {
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent { agents, context, task }) => {
// Update the active agents list from the event
self.active_agents.clear();
for agent in agents {
@@ -1139,6 +1149,34 @@ impl ChatWidget<'_> {
},
});
}
// Store shared context and task
self.agent_context = context;
self.agent_task = task;
// Update overall task status based on agent states
self.overall_task_status = if self.active_agents.is_empty() {
"preparing".to_string()
} else if self.active_agents.iter().any(|a| matches!(a.status, AgentStatus::Running)) {
"running".to_string()
} else if self.active_agents.iter().all(|a| matches!(a.status, AgentStatus::Completed)) {
"complete".to_string()
} else if self.active_agents.iter().any(|a| matches!(a.status, AgentStatus::Failed)) {
"failed".to_string()
} else {
"planning".to_string()
};
// Clear agents HUD when task is complete or failed
if matches!(self.overall_task_status.as_str(), "complete" | "failed") {
self.active_agents.clear();
self.agents_ready_to_start = false;
self.agent_context = None;
self.agent_task = None;
self.last_agent_prompt = None;
self.sparkline_data.borrow_mut().clear();
}
// Reset ready to start flag when we get actual agent updates
if !self.active_agents.is_empty() {
self.agents_ready_to_start = false;
@@ -1233,6 +1271,89 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn prepare_agents(&mut self) {
// Set the flag to show agents are ready to start
self.agents_ready_to_start = true;
// Initialize sparkline with some data so it shows immediately
{
let mut sparkline_data = self.sparkline_data.borrow_mut();
if sparkline_data.is_empty() {
// Add initial low activity data for preparing phase
for _ in 0..10 {
sparkline_data.push((2, false));
}
tracing::info!("Initialized sparkline data with {} points for preparing phase", sparkline_data.len());
}
} // Drop the borrow here
self.request_redraw();
}
/// Update sparkline data with randomized activity based on agent count
fn update_sparkline_data(&self) {
let now = std::time::Instant::now();
// Update every 100ms for smooth animation
if now.duration_since(*self.last_sparkline_update.borrow()).as_millis() < 100 {
return;
}
*self.last_sparkline_update.borrow_mut() = now;
// Calculate base height based on number of agents and status
let agent_count = self.active_agents.len();
let is_planning = self.overall_task_status == "planning";
let base_height = if agent_count == 0 && self.agents_ready_to_start {
2 // Minimal activity when preparing
} else if is_planning && agent_count > 0 {
3 // Low activity during planning phase
} else if agent_count == 1 {
5 // Low activity for single agent
} else if agent_count == 2 {
10 // Medium activity for two agents
} else if agent_count >= 3 {
15 // High activity for multiple agents
} else {
0 // No activity when no agents
};
// Don't generate data if there's no activity
if base_height == 0 {
return;
}
// Generate random variation
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
now.elapsed().as_nanos().hash(&mut hasher);
let random_seed = hasher.finish();
// More variation during planning phase for visibility (+/- 50%)
// Less variation during running for stability (+/- 30%)
let variation_percent = if self.agents_ready_to_start && self.active_agents.is_empty() {
50 // More variation during planning for visibility
} else {
30 // Standard variation during running
};
let variation_range = variation_percent * 2; // e.g., 100 for +/- 50%
let variation = ((random_seed % variation_range) as i32 - variation_percent as i32) * base_height as i32 / 100;
let height = ((base_height as i32 + variation).max(1) as u64).min(20);
// Check if any agents are completed
let has_completed = self.active_agents.iter()
.any(|a| matches!(a.status, AgentStatus::Completed));
// Keep a rolling window of 60 data points (about 6 seconds at 100ms intervals)
let mut sparkline_data = self.sparkline_data.borrow_mut();
sparkline_data.push((height, has_completed));
if sparkline_data.len() > 60 {
sparkline_data.remove(0);
}
}
pub(crate) fn set_reasoning_effort(&mut self, new_effort: ReasoningEffort) {
// Update the config
self.config.model_reasoning_effort = new_effort;
@@ -1669,6 +1790,7 @@ impl ChatWidget<'_> {
);
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let layout_areas = self.layout_areas(area);
let bottom_pane_area = if layout_areas.len() == 4 {
@@ -1995,154 +2117,57 @@ impl ChatWidget<'_> {
self.render_browser_panel(chunks[0], buf);
self.render_agent_panel(chunks[1], buf);
} else if has_browser_screenshot {
// Only browser: use full width
self.render_browser_panel(padded_area, buf);
// Only browser: 50% width on the left side
let chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas::<2>(padded_area);
self.render_browser_panel(chunks[0], buf);
// Right side remains empty
} else if has_active_agents {
// Only agents: use full width
self.render_agent_panel(padded_area, buf);
// Only agents: 50% width on the left side
let chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas::<2>(padded_area);
self.render_agent_panel(chunks[0], buf);
// Right side remains empty
}
}
/// Render the browser panel (left side when both panels are shown)
fn render_browser_panel(&self, area: Rect, buf: &mut Buffer) {
use ratatui::style::Style;
use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use ratatui::widgets::{Block, Borders, Widget};
if let Ok(screenshot_lock) = self.latest_browser_screenshot.lock() {
if let Some((screenshot_path, url)) = &*screenshot_lock {
// Split the HUD into two parts: left for preview, right for status (50% width)
let chunks =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.areas::<2>(area);
let screenshot_area = chunks[0]; // Preview on the left
let info_area = chunks[1]; // Status on the right
// Left side: Screenshot with URL in title
// Use the full area for the browser preview
let screenshot_block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", url)) // URL in the title
.title(format!(" Browser - {} ", url))
.border_style(Style::default().fg(crate::colors::border()));
let inner_screenshot = screenshot_block.inner(screenshot_area);
screenshot_block.render(screenshot_area, buf);
let inner_screenshot = screenshot_block.inner(area);
screenshot_block.render(area, buf);
// Render the screenshot
// Render the screenshot using the full inner area
self.render_screenshot_highlevel(screenshot_path, inner_screenshot, buf);
// Right side: Browser status
let info_block = Block::default()
.borders(Borders::ALL)
.title(" Browser Status ")
.border_style(Style::default().fg(crate::colors::border()));
let inner_info = info_block.inner(info_area);
info_block.render(info_area, buf);
// Display browser status information
let mut lines = vec![];
// Add browser status information
if self.browser_manager.is_enabled_sync() {
lines.push(RLine::from(vec![
Span::styled("URL: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
url.clone(),
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD),
),
]));
if self.browser_manager.is_enabled_sync() {
lines.push(RLine::from(vec![
Span::styled(
"Status: ",
Style::default().fg(crate::colors::text_dim()),
),
Span::styled("Active", Style::default().fg(crate::colors::success())),
]));
} else {
lines.push(RLine::from(vec![
Span::styled(
"Status: ",
Style::default().fg(crate::colors::text_dim()),
),
Span::styled("Inactive", Style::default().fg(crate::colors::warning())),
]));
}
let info_paragraph = Paragraph::new(lines);
info_paragraph.render(inner_info, buf);
// Screenshot panel
let screenshot_block = Block::default()
.borders(Borders::ALL)
.title(" Preview ")
.border_style(Style::default().fg(crate::colors::border()));
let inner_screenshot = screenshot_block.inner(screenshot_area);
screenshot_block.render(screenshot_area, buf);
// Render the screenshot (using existing method)
if let Some((screenshot_path, _)) = &*screenshot_lock {
self.render_screenshot_highlevel(screenshot_path, inner_screenshot, buf);
}
} else {
// Just show browser info in limited space
let info_block = Block::default()
.borders(Borders::ALL)
.title(" Browser ")
.border_style(Style::default().fg(crate::colors::border()));
let inner_info = info_block.inner(area);
info_block.render(area, buf);
let mut lines = vec![];
// Truncate URL if needed
let max_url_len = area.width.saturating_sub(10) as usize;
let display_url = if url.len() > max_url_len {
format!("{}...", &url[..max_url_len.saturating_sub(3)])
} else {
url.clone()
};
lines.push(RLine::from(vec![
Span::styled("URL: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(display_url, Style::default().fg(crate::colors::text())),
]));
let status = if self.browser_manager.is_enabled_sync() {
"Active"
} else {
"Inactive"
};
lines.push(RLine::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(
status,
Style::default().fg(if self.browser_manager.is_enabled_sync() {
crate::colors::success()
} else {
crate::colors::warning()
}),
),
]));
let info_paragraph = Paragraph::new(lines);
info_paragraph.render(inner_info, buf);
}
}
}
}
/// Render the agent status panel in the HUD
fn render_agent_panel(&self, area: Rect, buf: &mut Buffer) {
use ratatui::style::Style;
use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use ratatui::text::{Line as RLine, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Sparkline, SparklineBar, Widget, Wrap};
// Update sparkline data for animation
if !self.active_agents.is_empty() || self.agents_ready_to_start {
self.update_sparkline_data();
}
// Agent status block
let agent_block = Block::default()
@@ -2153,24 +2178,107 @@ impl ChatWidget<'_> {
let inner_agent = agent_block.inner(area);
agent_block.render(area, buf);
// Display agent statuses
let mut lines = vec![];
if self.agents_ready_to_start && self.active_agents.is_empty() {
// Show "Ready to start" message when agents are expected
lines.push(RLine::from(vec![Span::styled(
"Ready to start",
Style::default()
.fg(crate::colors::info())
.add_modifier(Modifier::ITALIC),
)]));
} else if self.active_agents.is_empty() {
lines.push(RLine::from(vec![Span::styled(
"No active agents",
Style::default().fg(crate::colors::text_dim()),
)]));
// Dynamically calculate sparkline height based on agent activity
// More agents = taller sparkline area
let agent_count = self.active_agents.len();
let sparkline_height = if agent_count == 0 && self.agents_ready_to_start {
1u16 // Minimal height when preparing
} else if agent_count == 0 {
0u16 // No sparkline when no agents
} else {
// Show agent names/models at top
(agent_count as u16 + 1).min(4) // 2-4 lines based on agent count
};
// Ensure we have enough space for both content and sparkline
// Reserve at least 3 lines for content (status + blank + message)
let min_content_height = 3u16;
let available_height = inner_agent.height;
let (actual_content_height, actual_sparkline_height) = if sparkline_height > 0 {
if available_height > min_content_height + sparkline_height {
// Enough space for both
(available_height.saturating_sub(sparkline_height), sparkline_height)
} else if available_height > min_content_height {
// Limited space - give minimum to content, rest to sparkline
(min_content_height, available_height.saturating_sub(min_content_height).min(sparkline_height))
} else {
// Very limited space - content only
(available_height, 0)
}
} else {
// No sparkline needed
(available_height, 0)
};
let content_area = Rect {
x: inner_agent.x,
y: inner_agent.y,
width: inner_agent.width,
height: actual_content_height,
};
let sparkline_area = Rect {
x: inner_agent.x,
y: inner_agent.y + actual_content_height,
width: inner_agent.width,
height: actual_sparkline_height,
};
// Build all content into a single Text structure for proper wrapping
let mut text_content = vec![];
// Add blank line at the top
text_content.push(RLine::from(" "));
// Add overall task status at the top
let status_color = match self.overall_task_status.as_str() {
"planning" => crate::colors::warning(),
"running" => crate::colors::info(),
"consolidating" => crate::colors::warning(),
"complete" => crate::colors::success(),
"failed" => crate::colors::error(),
_ => crate::colors::text_dim(),
};
text_content.push(RLine::from(vec![
Span::from(" "),
Span::styled(
"Status: ",
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD),
),
Span::styled(
&self.overall_task_status,
Style::default().fg(status_color),
),
]));
// Add blank line
text_content.push(RLine::from(" "));
// Display agent statuses
if self.agents_ready_to_start && self.active_agents.is_empty() {
// Show "Building context..." message when agents are expected
text_content.push(RLine::from(vec![
Span::from(" "),
Span::styled(
"Building context...",
Style::default()
.fg(crate::colors::text_dim())
.add_modifier(Modifier::ITALIC),
),
]));
} else if self.active_agents.is_empty() {
text_content.push(RLine::from(vec![
Span::from(" "),
Span::styled(
"No active agents",
Style::default().fg(crate::colors::text_dim()),
),
]));
} else {
// Show agent names/models
for agent in &self.active_agents {
let status_color = match agent.status {
AgentStatus::Pending => crate::colors::warning(),
@@ -2186,53 +2294,115 @@ impl ChatWidget<'_> {
AgentStatus::Failed => "failed",
};
// Truncate name to fit nicely
let display_name = if agent.name.len() > 30 {
format!("{}...", &agent.name[..27])
} else {
agent.name.clone()
};
lines.push(RLine::from(vec![
text_content.push(RLine::from(vec![
Span::from(" "),
Span::styled(
display_name,
format!("{}: ", agent.name),
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD),
),
Span::styled(" - ", Style::default().fg(crate::colors::text_dim())),
Span::styled(status_text, Style::default().fg(status_color)),
]));
}
}
// Add prompt display at bottom if available
if let Some(ref prompt) = self.last_agent_prompt {
if !lines.is_empty() {
lines.push(RLine::from("")); // Empty line separator
}
lines.push(RLine::from(vec![Span::styled(
"Prompt: ",
Style::default()
.fg(crate::colors::text_dim())
.add_modifier(Modifier::ITALIC),
)]));
// Wrap long prompts
let prompt_text = if prompt.len() > 60 {
format!("{}...", &prompt[..57])
} else {
prompt.clone()
// Calculate how much vertical space the fixed content takes
let fixed_content_height = text_content.len() as u16;
// Create the first paragraph for the fixed content (status and agents) without wrapping
let fixed_paragraph = Paragraph::new(Text::from(text_content));
// Render the fixed content first
let fixed_area = Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: fixed_content_height.min(content_area.height),
};
fixed_paragraph.render(fixed_area, buf);
// Calculate remaining area for wrapped content
let remaining_height = content_area.height.saturating_sub(fixed_content_height);
if remaining_height > 0 {
let wrapped_area = Rect {
x: content_area.x,
y: content_area.y + fixed_content_height,
width: content_area.width,
height: remaining_height,
};
// Add context and task sections with proper wrapping in the remaining area
let mut wrapped_content = vec![];
lines.push(RLine::from(vec![Span::styled(
prompt_text,
Style::default().fg(crate::colors::text_dim()),
)]));
if let Some(ref task) = self.agent_task {
wrapped_content.push(RLine::from(" ")); // Empty line separator
wrapped_content.push(RLine::from(vec![
Span::from(" "),
Span::styled(
"Task:",
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD),
),
Span::from(" "),
Span::styled(
task,
Style::default().fg(crate::colors::text_dim()),
),
]));
}
if !wrapped_content.is_empty() {
// Create paragraph with wrapping enabled for the long text content
let wrapped_paragraph = Paragraph::new(Text::from(wrapped_content))
.wrap(Wrap { trim: false });
wrapped_paragraph.render(wrapped_area, buf);
}
}
let agent_paragraph = Paragraph::new(lines);
agent_paragraph.render(inner_agent, buf);
// Render sparkline at the bottom if we have data and agents are active
let sparkline_data = self.sparkline_data.borrow();
// Debug logging
tracing::debug!("Sparkline render check: data_len={}, agents={}, ready={}, height={}, actual_height={}, area={:?}",
sparkline_data.len(),
self.active_agents.len(),
self.agents_ready_to_start,
sparkline_height,
actual_sparkline_height,
sparkline_area
);
if !sparkline_data.is_empty() && (!self.active_agents.is_empty() || self.agents_ready_to_start) && actual_sparkline_height > 0 {
// Convert data to SparklineBar with colors based on completion status
let bars: Vec<SparklineBar> = sparkline_data.iter()
.map(|(value, is_completed)| {
let color = if *is_completed {
crate::colors::success() // Green for completed
} else {
crate::colors::border() // Border color for normal activity
};
SparklineBar::from(*value).style(Style::default().fg(color))
})
.collect();
// Use dynamic max based on the actual data for better visibility
// During preparing/planning, values are small (2-3), during running they're larger (5-15)
// For planning phase with single line, use smaller max for better visibility
let max_value = if self.agents_ready_to_start && self.active_agents.is_empty() {
// Planning phase - use smaller max for better visibility of 1-3 range
sparkline_data.iter().map(|(v, _)| *v).max().unwrap_or(4).max(4)
} else {
// Running phase - use larger max
sparkline_data.iter().map(|(v, _)| *v).max().unwrap_or(10).max(10)
};
let sparkline = Sparkline::default()
.data(bars)
.max(max_value); // Dynamic max for better visibility
sparkline.render(sparkline_area, buf);
}
}
fn begin_stream(&mut self, kind: StreamKind) {