use crate::agents::types::{ActionResult, AgentAction};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionCategory {
File,
Command,
Git,
WebSearch,
}
impl ActionCategory {
pub fn header(&self) -> &str {
match self {
ActionCategory::File => "File Operations:",
ActionCategory::Command => "Commands:",
ActionCategory::Git => "Git Operations:",
ActionCategory::WebSearch => "Web Searches:",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionStatus {
Pending,
Executing,
Completed,
Failed,
Skipped,
}
impl ActionStatus {
pub fn indicator(&self) -> &str {
match self {
ActionStatus::Pending => "•",
ActionStatus::Executing => "...",
ActionStatus::Completed => "✓",
ActionStatus::Failed => "✗",
ActionStatus::Skipped => "-",
}
}
pub fn is_terminal(&self) -> bool {
matches!(
self,
ActionStatus::Completed | ActionStatus::Failed | ActionStatus::Skipped
)
}
}
#[derive(Debug, Clone)]
pub struct PlannedAction {
pub action: AgentAction,
pub status: ActionStatus,
pub result: Option<ActionResult>,
pub error: Option<String>,
}
impl PlannedAction {
pub fn new(action: AgentAction) -> Self {
Self {
action,
status: ActionStatus::Pending,
result: None,
error: None,
}
}
pub fn description(&self) -> String {
match &self.action {
AgentAction::ReadFile { paths } => {
if paths.len() == 1 {
format!("Read {}", paths[0])
} else {
format!("Read {} files", paths.len())
}
}
AgentAction::WriteFile { path, .. } => format!("Write {}", path),
AgentAction::EditFile { path, .. } => format!("Edit {}", path),
AgentAction::DeleteFile { path } => format!("Delete {}", path),
AgentAction::CreateDirectory { path } => format!("Create dir {}", path),
AgentAction::ExecuteCommand { command, .. } => format!("Run: {}", command),
AgentAction::GitDiff { paths } => {
if paths.len() == 1 {
format!("Git diff {}", paths[0].as_deref().unwrap_or("*"))
} else {
format!("Git diff {} paths", paths.len())
}
}
AgentAction::GitCommit { message, .. } => format!("Git commit: {}", message),
AgentAction::GitStatus => "Git status".to_string(),
AgentAction::WebSearch { queries } => {
if queries.len() == 1 {
format!("Search: {}", queries[0].0)
} else {
format!("Search {} queries", queries.len())
}
}
AgentAction::WebFetch { url } => format!("Fetch: {}", url),
}
}
pub fn action_type(&self) -> &str {
match &self.action {
AgentAction::ReadFile { .. } => "Read",
AgentAction::WriteFile { .. } => "Write",
AgentAction::EditFile { .. } => "Edit",
AgentAction::DeleteFile { .. } => "Delete",
AgentAction::CreateDirectory { .. } => "Bash",
AgentAction::ExecuteCommand { .. } => "Bash",
AgentAction::GitDiff { .. } => "Bash",
AgentAction::GitCommit { .. } => "Bash",
AgentAction::GitStatus => "Bash",
AgentAction::WebSearch { .. } => "Web Search",
AgentAction::WebFetch { .. } => "Web Fetch",
}
}
pub fn category(&self) -> ActionCategory {
match &self.action {
AgentAction::ReadFile { .. }
| AgentAction::WriteFile { .. }
| AgentAction::EditFile { .. }
| AgentAction::DeleteFile { .. }
| AgentAction::CreateDirectory { .. } => ActionCategory::File,
AgentAction::ExecuteCommand { .. } => ActionCategory::Command,
AgentAction::GitDiff { .. }
| AgentAction::GitCommit { .. }
| AgentAction::GitStatus => ActionCategory::Git,
AgentAction::WebSearch { .. } | AgentAction::WebFetch { .. } => ActionCategory::WebSearch,
}
}
}
#[derive(Debug, Default)]
struct CategorizedActions<'a> {
file: Vec<&'a PlannedAction>,
command: Vec<&'a PlannedAction>,
git: Vec<&'a PlannedAction>,
}
impl<'a> CategorizedActions<'a> {
fn from_actions(actions: &'a [PlannedAction]) -> Self {
let mut categorized = Self::default();
for action in actions {
match action.category() {
ActionCategory::File => categorized.file.push(action),
ActionCategory::Command => categorized.command.push(action),
ActionCategory::Git => categorized.git.push(action),
ActionCategory::WebSearch => {} }
}
categorized
}
fn render(&self, output: &mut String, numbered: bool, show_errors: bool) {
self.render_category(output, &self.file, ActionCategory::File, numbered, show_errors);
self.render_category(output, &self.command, ActionCategory::Command, numbered, show_errors);
self.render_category(output, &self.git, ActionCategory::Git, numbered, show_errors);
}
fn render_category(
&self,
output: &mut String,
actions: &[&PlannedAction],
category: ActionCategory,
numbered: bool,
show_errors: bool,
) {
if actions.is_empty() {
return;
}
output.push_str(category.header());
output.push('\n');
for (i, action) in actions.iter().enumerate() {
if numbered {
output.push_str(&format!(
" {}. {} {}\n",
i + 1,
action.status.indicator(),
action.description()
));
} else {
output.push_str(&format!(
" {} {}\n",
action.status.indicator(),
action.description()
));
}
if show_errors {
if let Some(ref err) = action.error {
output.push_str(&format!(" Error: {}\n", err));
}
}
}
output.push('\n');
}
}
#[derive(Debug, Clone)]
pub struct Plan {
pub actions: Vec<PlannedAction>,
pub created_at: Instant,
pub explanation: Option<String>,
pub display_text: String,
}
impl Plan {
pub fn new(actions: Vec<AgentAction>) -> Self {
Self::with_explanation(None, actions)
}
pub fn with_explanation(explanation: Option<String>, actions: Vec<AgentAction>) -> Self {
let planned_actions: Vec<PlannedAction> =
actions.into_iter().map(PlannedAction::new).collect();
let display_text = Self::format_display_with_explanation(&explanation, &planned_actions);
Self {
actions: planned_actions,
created_at: Instant::now(),
explanation,
display_text,
}
}
fn format_display_with_explanation(
explanation: &Option<String>,
actions: &[PlannedAction],
) -> String {
let mut output = String::new();
if let Some(exp) = explanation {
let trimmed = exp.trim();
if !trimmed.is_empty() {
output.push_str(trimmed);
output.push_str("\n\n");
}
}
let actions_text = Self::format_display_actions(actions);
output.push_str(&actions_text);
output
}
fn format_display_actions(actions: &[PlannedAction]) -> String {
if actions.is_empty() {
return "No actions in plan".to_string();
}
let mut output = String::new();
output.push_str("Plan: Ready to execute\n\n");
let categorized = CategorizedActions::from_actions(actions);
categorized.render(&mut output, true, false);
output.push_str("Approve with Y, Cancel with N");
output
}
pub fn update_action_status(
&mut self,
index: usize,
status: ActionStatus,
result: Option<ActionResult>,
error: Option<String>,
) {
if let Some(action) = self.actions.get_mut(index) {
action.status = status;
action.result = result;
action.error = error;
}
self.regenerate_display();
}
fn regenerate_display(&mut self) {
let stats = self.stats();
let mut output = String::new();
if stats.completed == stats.total {
output.push_str(&format!(
"Plan: Completed ({}/{})\n\n",
stats.completed, stats.total
));
} else if stats.failed > 0 {
output.push_str(&format!(
"Plan: In Progress ({}/{}, {} failed)\n\n",
stats.completed, stats.total, stats.failed
));
} else {
output.push_str(&format!(
"Plan: In Progress ({}/{})\n\n",
stats.completed, stats.total
));
}
let categorized = CategorizedActions::from_actions(&self.actions);
categorized.render(&mut output, false, true);
if stats.is_complete() {
output.push_str("Plan: Complete");
} else {
output.push_str("Executing plan... Alt+Esc to abort");
}
self.display_text = output;
}
pub fn next_pending_action(&self) -> Option<(usize, &PlannedAction)> {
self.actions
.iter()
.enumerate()
.find(|(_, a)| a.status == ActionStatus::Pending)
}
pub fn stats(&self) -> PlanStats {
PlanStats {
total: self.actions.len(),
completed: self
.actions
.iter()
.filter(|a| a.status == ActionStatus::Completed)
.count(),
failed: self
.actions
.iter()
.filter(|a| a.status == ActionStatus::Failed)
.count(),
skipped: self
.actions
.iter()
.filter(|a| a.status == ActionStatus::Skipped)
.count(),
executing: self
.actions
.iter()
.filter(|a| a.status == ActionStatus::Executing)
.count(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct PlanStats {
pub total: usize,
pub completed: usize,
pub failed: usize,
pub skipped: usize,
pub executing: usize,
}
impl PlanStats {
pub fn completion_percent(&self) -> u8 {
if self.total == 0 {
100
} else {
((self.completed + self.failed + self.skipped) as f64 / self.total as f64 * 100.0) as u8
}
}
pub fn is_complete(&self) -> bool {
self.completed + self.failed + self.skipped == self.total
}
pub fn has_failures(&self) -> bool {
self.failed > 0
}
pub fn status_message(&self) -> String {
if self.is_complete() {
if self.has_failures() {
format!(
"Plan completed: {}/{} successful, {} failed",
self.completed, self.total, self.failed
)
} else {
format!("Plan completed: all {} actions successful", self.total)
}
} else {
format!(
"Plan: {} executing, {}/{} completed",
self.executing, self.completed, self.total
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action_status_indicators() {
assert_eq!(ActionStatus::Pending.indicator(), "•");
assert_eq!(ActionStatus::Executing.indicator(), "...");
assert_eq!(ActionStatus::Completed.indicator(), "✓");
assert_eq!(ActionStatus::Failed.indicator(), "✗");
assert_eq!(ActionStatus::Skipped.indicator(), "-");
}
#[test]
fn test_planned_action_new() {
let action = AgentAction::ReadFile {
paths: vec!["test.txt".to_string()],
};
let planned = PlannedAction::new(action);
assert_eq!(planned.status, ActionStatus::Pending);
assert!(planned.result.is_none());
assert!(planned.error.is_none());
}
#[test]
fn test_plan_stats() {
let mut plan = Plan::new(vec![
AgentAction::ReadFile {
paths: vec!["a.txt".to_string()],
},
AgentAction::WriteFile {
path: "b.txt".to_string(),
content: "content".to_string(),
},
]);
let mut stats = plan.stats();
assert_eq!(stats.total, 2);
assert_eq!(stats.completed, 0);
assert!(!stats.is_complete());
plan.update_action_status(0, ActionStatus::Completed, None, None);
stats = plan.stats();
assert_eq!(stats.completed, 1);
assert!(!stats.is_complete());
plan.update_action_status(1, ActionStatus::Completed, None, None);
stats = plan.stats();
assert_eq!(stats.completed, 2);
assert!(stats.is_complete());
}
}