1use crate::agents::types::{ActionResult, AgentAction};
2use std::time::Instant;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ActionCategory {
7 File,
9 Command,
11 Git,
13 WebSearch,
15}
16
17impl ActionCategory {
18 pub fn header(&self) -> &str {
20 match self {
21 ActionCategory::File => "File Operations:",
22 ActionCategory::Command => "Commands:",
23 ActionCategory::Git => "Git Operations:",
24 ActionCategory::WebSearch => "Web Searches:",
25 }
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ActionStatus {
32 Pending,
34 Executing,
36 Completed,
38 Failed,
40 Skipped,
42}
43
44impl ActionStatus {
45 pub fn indicator(&self) -> &str {
47 match self {
48 ActionStatus::Pending => "•",
49 ActionStatus::Executing => "...",
50 ActionStatus::Completed => "✓",
51 ActionStatus::Failed => "✗",
52 ActionStatus::Skipped => "-",
53 }
54 }
55
56 pub fn is_terminal(&self) -> bool {
58 matches!(
59 self,
60 ActionStatus::Completed | ActionStatus::Failed | ActionStatus::Skipped
61 )
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct PlannedAction {
68 pub action: AgentAction,
70 pub status: ActionStatus,
72 pub result: Option<ActionResult>,
74 pub error: Option<String>,
76}
77
78impl PlannedAction {
79 pub fn new(action: AgentAction) -> Self {
81 Self {
82 action,
83 status: ActionStatus::Pending,
84 result: None,
85 error: None,
86 }
87 }
88
89 pub fn description(&self) -> String {
91 match &self.action {
92 AgentAction::ReadFile { paths } => {
93 if paths.len() == 1 {
94 format!("Read {}", paths[0])
95 } else {
96 format!("Read {} files", paths.len())
97 }
98 }
99 AgentAction::WriteFile { path, .. } => format!("Write {}", path),
100 AgentAction::EditFile { path, .. } => format!("Edit {}", path),
101 AgentAction::DeleteFile { path } => format!("Delete {}", path),
102 AgentAction::CreateDirectory { path } => format!("Create dir {}", path),
103 AgentAction::ExecuteCommand { command, .. } => format!("Run: {}", command),
104 AgentAction::GitDiff { paths } => {
105 if paths.len() == 1 {
106 format!("Git diff {}", paths[0].as_deref().unwrap_or("*"))
107 } else {
108 format!("Git diff {} paths", paths.len())
109 }
110 }
111 AgentAction::GitCommit { message, .. } => format!("Git commit: {}", message),
112 AgentAction::GitStatus => "Git status".to_string(),
113 AgentAction::WebSearch { queries } => {
114 if queries.len() == 1 {
115 format!("Search: {}", queries[0].0)
116 } else {
117 format!("Search {} queries", queries.len())
118 }
119 }
120 AgentAction::WebFetch { url } => format!("Fetch: {}", url),
121 }
122 }
123
124 pub fn action_type(&self) -> &str {
126 match &self.action {
127 AgentAction::ReadFile { .. } => "Read",
128 AgentAction::WriteFile { .. } => "Write",
129 AgentAction::EditFile { .. } => "Edit",
130 AgentAction::DeleteFile { .. } => "Delete",
131 AgentAction::CreateDirectory { .. } => "Bash",
132 AgentAction::ExecuteCommand { .. } => "Bash",
133 AgentAction::GitDiff { .. } => "Bash",
134 AgentAction::GitCommit { .. } => "Bash",
135 AgentAction::GitStatus => "Bash",
136 AgentAction::WebSearch { .. } => "Web Search",
137 AgentAction::WebFetch { .. } => "Web Fetch",
138 }
139 }
140
141 pub fn category(&self) -> ActionCategory {
143 match &self.action {
144 AgentAction::ReadFile { .. }
145 | AgentAction::WriteFile { .. }
146 | AgentAction::EditFile { .. }
147 | AgentAction::DeleteFile { .. }
148 | AgentAction::CreateDirectory { .. } => ActionCategory::File,
149 AgentAction::ExecuteCommand { .. } => ActionCategory::Command,
150 AgentAction::GitDiff { .. }
151 | AgentAction::GitCommit { .. }
152 | AgentAction::GitStatus => ActionCategory::Git,
153 AgentAction::WebSearch { .. } | AgentAction::WebFetch { .. } => ActionCategory::WebSearch,
154 }
155 }
156}
157
158#[derive(Debug, Default)]
160struct CategorizedActions<'a> {
161 file: Vec<&'a PlannedAction>,
162 command: Vec<&'a PlannedAction>,
163 git: Vec<&'a PlannedAction>,
164}
165
166impl<'a> CategorizedActions<'a> {
167 fn from_actions(actions: &'a [PlannedAction]) -> Self {
169 let mut categorized = Self::default();
170 for action in actions {
171 match action.category() {
172 ActionCategory::File => categorized.file.push(action),
173 ActionCategory::Command => categorized.command.push(action),
174 ActionCategory::Git => categorized.git.push(action),
175 ActionCategory::WebSearch => {} }
177 }
178 categorized
179 }
180
181 fn render(&self, output: &mut String, numbered: bool, show_errors: bool) {
183 self.render_category(output, &self.file, ActionCategory::File, numbered, show_errors);
184 self.render_category(output, &self.command, ActionCategory::Command, numbered, show_errors);
185 self.render_category(output, &self.git, ActionCategory::Git, numbered, show_errors);
186 }
187
188 fn render_category(
190 &self,
191 output: &mut String,
192 actions: &[&PlannedAction],
193 category: ActionCategory,
194 numbered: bool,
195 show_errors: bool,
196 ) {
197 if actions.is_empty() {
198 return;
199 }
200
201 output.push_str(category.header());
202 output.push('\n');
203
204 for (i, action) in actions.iter().enumerate() {
205 if numbered {
206 output.push_str(&format!(
207 " {}. {} {}\n",
208 i + 1,
209 action.status.indicator(),
210 action.description()
211 ));
212 } else {
213 output.push_str(&format!(
214 " {} {}\n",
215 action.status.indicator(),
216 action.description()
217 ));
218 }
219
220 if show_errors {
221 if let Some(ref err) = action.error {
222 output.push_str(&format!(" Error: {}\n", err));
223 }
224 }
225 }
226 output.push('\n');
227 }
228}
229
230#[derive(Debug, Clone)]
232pub struct Plan {
233 pub actions: Vec<PlannedAction>,
235 pub created_at: Instant,
237 pub explanation: Option<String>,
239 pub display_text: String,
241}
242
243impl Plan {
244 pub fn new(actions: Vec<AgentAction>) -> Self {
246 Self::with_explanation(None, actions)
247 }
248
249 pub fn with_explanation(explanation: Option<String>, actions: Vec<AgentAction>) -> Self {
251 let planned_actions: Vec<PlannedAction> =
252 actions.into_iter().map(PlannedAction::new).collect();
253
254 let display_text = Self::format_display_with_explanation(&explanation, &planned_actions);
255
256 Self {
257 actions: planned_actions,
258 created_at: Instant::now(),
259 explanation,
260 display_text,
261 }
262 }
263
264 fn format_display_with_explanation(
266 explanation: &Option<String>,
267 actions: &[PlannedAction],
268 ) -> String {
269 let mut output = String::new();
270
271 if let Some(exp) = explanation {
273 let trimmed = exp.trim();
274 if !trimmed.is_empty() {
275 output.push_str(trimmed);
276 output.push_str("\n\n");
277 }
278 }
279
280 let actions_text = Self::format_display_actions(actions);
282 output.push_str(&actions_text);
283 output
284 }
285
286 fn format_display_actions(actions: &[PlannedAction]) -> String {
288 if actions.is_empty() {
289 return "No actions in plan".to_string();
290 }
291
292 let mut output = String::new();
293 output.push_str("Plan: Ready to execute\n\n");
294
295 let categorized = CategorizedActions::from_actions(actions);
296 categorized.render(&mut output, true, false); output.push_str("Approve with Y, Cancel with N");
299 output
300 }
301
302 pub fn update_action_status(
304 &mut self,
305 index: usize,
306 status: ActionStatus,
307 result: Option<ActionResult>,
308 error: Option<String>,
309 ) {
310 if let Some(action) = self.actions.get_mut(index) {
311 action.status = status;
312 action.result = result;
313 action.error = error;
314 }
315 self.regenerate_display();
316 }
317
318 fn regenerate_display(&mut self) {
320 let stats = self.stats();
321 let mut output = String::new();
322
323 if stats.completed == stats.total {
325 output.push_str(&format!(
326 "Plan: Completed ({}/{})\n\n",
327 stats.completed, stats.total
328 ));
329 } else if stats.failed > 0 {
330 output.push_str(&format!(
331 "Plan: In Progress ({}/{}, {} failed)\n\n",
332 stats.completed, stats.total, stats.failed
333 ));
334 } else {
335 output.push_str(&format!(
336 "Plan: In Progress ({}/{})\n\n",
337 stats.completed, stats.total
338 ));
339 }
340
341 let categorized = CategorizedActions::from_actions(&self.actions);
343 categorized.render(&mut output, false, true);
344
345 if stats.is_complete() {
347 output.push_str("Plan: Complete");
348 } else {
349 output.push_str("Executing plan... Alt+Esc to abort");
350 }
351
352 self.display_text = output;
353 }
354
355 pub fn next_pending_action(&self) -> Option<(usize, &PlannedAction)> {
357 self.actions
358 .iter()
359 .enumerate()
360 .find(|(_, a)| a.status == ActionStatus::Pending)
361 }
362
363 pub fn stats(&self) -> PlanStats {
365 PlanStats {
366 total: self.actions.len(),
367 completed: self
368 .actions
369 .iter()
370 .filter(|a| a.status == ActionStatus::Completed)
371 .count(),
372 failed: self
373 .actions
374 .iter()
375 .filter(|a| a.status == ActionStatus::Failed)
376 .count(),
377 skipped: self
378 .actions
379 .iter()
380 .filter(|a| a.status == ActionStatus::Skipped)
381 .count(),
382 executing: self
383 .actions
384 .iter()
385 .filter(|a| a.status == ActionStatus::Executing)
386 .count(),
387 }
388 }
389}
390
391#[derive(Debug, Clone, Copy)]
393pub struct PlanStats {
394 pub total: usize,
395 pub completed: usize,
396 pub failed: usize,
397 pub skipped: usize,
398 pub executing: usize,
399}
400
401impl PlanStats {
402 pub fn completion_percent(&self) -> u8 {
404 if self.total == 0 {
405 100
406 } else {
407 ((self.completed + self.failed + self.skipped) as f64 / self.total as f64 * 100.0) as u8
408 }
409 }
410
411 pub fn is_complete(&self) -> bool {
413 self.completed + self.failed + self.skipped == self.total
414 }
415
416 pub fn has_failures(&self) -> bool {
418 self.failed > 0
419 }
420
421 pub fn status_message(&self) -> String {
423 if self.is_complete() {
424 if self.has_failures() {
425 format!(
426 "Plan completed: {}/{} successful, {} failed",
427 self.completed, self.total, self.failed
428 )
429 } else {
430 format!("Plan completed: all {} actions successful", self.total)
431 }
432 } else {
433 format!(
434 "Plan: {} executing, {}/{} completed",
435 self.executing, self.completed, self.total
436 )
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn test_action_status_indicators() {
447 assert_eq!(ActionStatus::Pending.indicator(), "•");
448 assert_eq!(ActionStatus::Executing.indicator(), "...");
449 assert_eq!(ActionStatus::Completed.indicator(), "✓");
450 assert_eq!(ActionStatus::Failed.indicator(), "✗");
451 assert_eq!(ActionStatus::Skipped.indicator(), "-");
452 }
453
454 #[test]
455 fn test_planned_action_new() {
456 let action = AgentAction::ReadFile {
457 paths: vec!["test.txt".to_string()],
458 };
459 let planned = PlannedAction::new(action);
460 assert_eq!(planned.status, ActionStatus::Pending);
461 assert!(planned.result.is_none());
462 assert!(planned.error.is_none());
463 }
464
465 #[test]
466 fn test_plan_stats() {
467 let mut plan = Plan::new(vec![
468 AgentAction::ReadFile {
469 paths: vec!["a.txt".to_string()],
470 },
471 AgentAction::WriteFile {
472 path: "b.txt".to_string(),
473 content: "content".to_string(),
474 },
475 ]);
476
477 let mut stats = plan.stats();
478 assert_eq!(stats.total, 2);
479 assert_eq!(stats.completed, 0);
480 assert!(!stats.is_complete());
481
482 plan.update_action_status(0, ActionStatus::Completed, None, None);
483 stats = plan.stats();
484 assert_eq!(stats.completed, 1);
485 assert!(!stats.is_complete());
486
487 plan.update_action_status(1, ActionStatus::Completed, None, None);
488 stats = plan.stats();
489 assert_eq!(stats.completed, 2);
490 assert!(stats.is_complete());
491 }
492}