feat: implement agent status panel in HUD display

- Add agent tracking with AgentInfo struct to monitor multi-agent tasks
- Create dual-panel HUD layout that shows browser and/or agent status based on what's active
- Track agent lifecycle: pending -> running -> completed/failed
- Detect /plan, /solve, /code commands and create appropriate agent entries
- Update agent status based on TaskStarted, TaskComplete, and Error events
- Implement conditional visibility: both panels when both active, single panel when only one active
- Clean up old render_browser_hud method and consolidate into new render_hud system

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

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-12 14:49:43 +10:00
co-authored by Claude
parent d2f9a76961
commit 271ded3e4d
+233 -15
View File
@@ -105,6 +105,8 @@ pub(crate) struct ChatWidget<'a> {
cached_cell_size: std::cell::OnceCell<(u16, u16)>,
// Scroll offset from bottom (0 = at bottom, positive = scrolled up)
scroll_offset: u16,
// Agent tracking for multi-agent tasks
active_agents: Vec<AgentInfo>,
}
struct UserMessage {
@@ -112,6 +114,21 @@ struct UserMessage {
image_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
struct AgentInfo {
name: String,
status: AgentStatus,
started_at: Option<std::time::Instant>,
}
#[derive(Debug, Clone, PartialEq)]
enum AgentStatus {
Pending,
Running,
Completed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StreamKind {
Answer,
@@ -232,9 +249,12 @@ impl ChatWidget<'_> {
.map(|lock| lock.is_some())
.unwrap_or(false);
// Check if there are active agents
let has_active_agents = !self.active_agents.is_empty();
let bottom_height = 6u16.max(self.bottom_pane.desired_height(area.width)).min(15);
if has_browser_screenshot {
if has_browser_screenshot || has_active_agents {
// match HUD padding used in render_browser_hud()
let horizontal_padding = 2u16;
let padded_area = Rect {
@@ -398,6 +418,7 @@ impl ChatWidget<'_> {
cached_picker: RefCell::new(None),
cached_cell_size: std::cell::OnceCell::new(),
scroll_offset: 0,
active_agents: Vec::new(),
}
}
@@ -525,6 +546,32 @@ impl ChatWidget<'_> {
let processed = crate::slash_command::process_slash_command_message(&actual_text);
match processed {
crate::slash_command::ProcessedCommand::ExpandedPrompt(expanded) => {
// Check if this is a multi-agent command
if actual_text.starts_with("/plan") || actual_text.starts_with("/solve") || actual_text.starts_with("/code") {
// Track as active agents
self.active_agents.clear(); // Clear any previous agents
// Determine agent names based on the command
let agent_names = if actual_text.starts_with("/plan") {
vec!["Planning Agent", "Research Agent", "Implementation Agent"]
} else if actual_text.starts_with("/solve") {
vec!["Analysis Agent", "Solution Agent", "Verification Agent"]
} else {
vec!["Code Analysis", "Implementation", "Testing"]
};
// Add agents with pending status
for name in agent_names {
self.active_agents.push(AgentInfo {
name: name.to_string(),
status: AgentStatus::Pending,
started_at: None,
});
}
self.request_redraw();
}
// Replace the slash command with the expanded prompt for the LLM
actual_text = expanded;
}
@@ -825,6 +872,16 @@ impl ChatWidget<'_> {
// Replace composer with single-line spinner while waiting.
self.bottom_pane
.update_status_text("waiting for model".to_string());
// Update first pending agent to running
for agent in &mut self.active_agents {
if agent.status == AgentStatus::Pending {
agent.status = AgentStatus::Running;
agent.started_at = Some(std::time::Instant::now());
break;
}
}
self.request_redraw();
}
EventMsg::TaskComplete(TaskCompleteEvent {
@@ -832,6 +889,17 @@ impl ChatWidget<'_> {
}) => {
self.bottom_pane.set_task_running(false);
self.bottom_pane.clear_live_ring();
// Mark all running agents as completed
for agent in &mut self.active_agents {
if agent.status == AgentStatus::Running {
agent.status = AgentStatus::Completed;
}
}
// Clear agents after a short delay (they'll remain visible for now)
// In a real implementation, you might want to keep them visible for a bit
self.request_redraw();
}
EventMsg::TokenCount(token_usage) => {
@@ -853,6 +921,14 @@ impl ChatWidget<'_> {
self.answer_buffer.clear();
self.reasoning_buffer.clear();
self.content_buffer.clear();
// Mark any running agents as failed
for agent in &mut self.active_agents {
if agent.status == AgentStatus::Running {
agent.status = AgentStatus::Failed;
}
}
self.request_redraw();
}
EventMsg::PlanUpdate(update) => {
@@ -1651,12 +1727,14 @@ fn render_screenshot_highlevel(&self, path: &PathBuf, area: Rect, buf: &mut Buff
}
impl ChatWidget<'_> {
/// Helper function to create a top-right positioned rect with proper aspect ratio
fn render_browser_hud(&self, area: Rect, buf: &mut Buffer) {
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use ratatui::text::{Line as RLine, Span};
/// Render the combined HUD with browser and/or agent panels based on what's active
fn render_hud(&self, area: Rect, buf: &mut Buffer) {
// Check what's active
let has_browser_screenshot = self.latest_browser_screenshot.lock()
.map(|lock| lock.is_some())
.unwrap_or(false);
let has_active_agents = !self.active_agents.is_empty();
// Add same horizontal padding as the Message input (2 chars on each side)
let horizontal_padding = 2u16;
@@ -1666,7 +1744,33 @@ impl ChatWidget<'_> {
width: area.width.saturating_sub(horizontal_padding * 2),
height: area.height,
};
// Determine layout based on what's active
if has_browser_screenshot && has_active_agents {
// Both panels: 50/50 split
let chunks = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.areas::<2>(padded_area);
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);
} else if has_active_agents {
// Only agents: use full width
self.render_agent_panel(padded_area, buf);
}
}
/// 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::widgets::{Block, Borders, Paragraph, Widget};
use ratatui::text::{Line as RLine, Span};
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)
@@ -1706,22 +1810,136 @@ impl ChatWidget<'_> {
// Add browser status information
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())),
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("Inactive", Style::default().fg(crate::colors::warning())),
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);
}
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::widgets::{Block, Borders, Paragraph, Widget};
use ratatui::text::{Line as RLine, Span};
// Agent status block
let agent_block = Block::default()
.borders(Borders::ALL)
.title(" Agents ")
.border_style(Style::default().fg(crate::colors::border()));
let inner_agent = agent_block.inner(area);
agent_block.render(area, buf);
// Display agent statuses
let mut lines = vec![];
if self.active_agents.is_empty() {
lines.push(RLine::from(vec![
Span::styled("No active agents", Style::default().fg(crate::colors::text_dim())),
]));
} else {
for agent in &self.active_agents {
let status_color = match agent.status {
AgentStatus::Pending => crate::colors::warning(),
AgentStatus::Running => crate::colors::info(),
AgentStatus::Completed => crate::colors::success(),
AgentStatus::Failed => crate::colors::error(),
};
let status_text = match agent.status {
AgentStatus::Pending => "pending",
AgentStatus::Running => "running",
AgentStatus::Completed => "completed",
AgentStatus::Failed => "failed",
};
lines.push(RLine::from(vec![
Span::styled(&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)),
]));
}
}
let agent_paragraph = Paragraph::new(lines);
agent_paragraph.render(inner_agent, buf);
}
fn begin_stream(&mut self, kind: StreamKind) {
if let Some(current) = self.current_stream {
if current != kind {
@@ -1857,9 +2075,9 @@ impl WidgetRef for &ChatWidget<'_> {
// Render status bar
self.render_status_bar(status_bar_area, buf);
// Render browser HUD if present
// Render HUD if present (browser and/or agents)
if let Some(hud_area) = hud_area {
self.render_browser_hud(hud_area, buf);
self.render_hud(hud_area, buf);
}
// Add horizontal padding to match the bottom pane (3 chars on each side)