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