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 { paths } => {
128 if paths.len() == 1 { "Read" } else { "ReadFiles" }
129 }
130 AgentAction::WriteFile { .. } => "Write",
131 AgentAction::EditFile { .. } => "Edit",
132 AgentAction::DeleteFile { .. } => "Delete",
133 AgentAction::CreateDirectory { .. } => "Create",
134 AgentAction::ExecuteCommand { .. } => "Bash",
135 AgentAction::GitDiff { paths } => {
136 if paths.len() == 1 { "GitDiff" } else { "GitDiffs" }
137 }
138 AgentAction::GitCommit { .. } => "GitCommit",
139 AgentAction::GitStatus => "GitStatus",
140 AgentAction::WebSearch { queries } => {
141 if queries.len() == 1 { "WebSearch" } else { "WebSearches" }
142 }
143 AgentAction::WebFetch { .. } => "WebFetch",
144 }
145 }
146
147 pub fn category(&self) -> ActionCategory {
149 match &self.action {
150 AgentAction::ReadFile { .. }
151 | AgentAction::WriteFile { .. }
152 | AgentAction::EditFile { .. }
153 | AgentAction::DeleteFile { .. }
154 | AgentAction::CreateDirectory { .. } => ActionCategory::File,
155 AgentAction::ExecuteCommand { .. } => ActionCategory::Command,
156 AgentAction::GitDiff { .. }
157 | AgentAction::GitCommit { .. }
158 | AgentAction::GitStatus => ActionCategory::Git,
159 AgentAction::WebSearch { .. } | AgentAction::WebFetch { .. } => ActionCategory::WebSearch,
160 }
161 }
162}
163
164#[derive(Debug, Default)]
166struct CategorizedActions<'a> {
167 file: Vec<&'a PlannedAction>,
168 command: Vec<&'a PlannedAction>,
169 git: Vec<&'a PlannedAction>,
170}
171
172impl<'a> CategorizedActions<'a> {
173 fn from_actions(actions: &'a [PlannedAction]) -> Self {
175 let mut categorized = Self::default();
176 for action in actions {
177 match action.category() {
178 ActionCategory::File => categorized.file.push(action),
179 ActionCategory::Command => categorized.command.push(action),
180 ActionCategory::Git => categorized.git.push(action),
181 ActionCategory::WebSearch => {} }
183 }
184 categorized
185 }
186
187 fn render(&self, output: &mut String, numbered: bool, show_errors: bool) {
189 self.render_category(output, &self.file, ActionCategory::File, numbered, show_errors);
190 self.render_category(output, &self.command, ActionCategory::Command, numbered, show_errors);
191 self.render_category(output, &self.git, ActionCategory::Git, numbered, show_errors);
192 }
193
194 fn render_category(
196 &self,
197 output: &mut String,
198 actions: &[&PlannedAction],
199 category: ActionCategory,
200 numbered: bool,
201 show_errors: bool,
202 ) {
203 if actions.is_empty() {
204 return;
205 }
206
207 output.push_str(category.header());
208 output.push('\n');
209
210 for (i, action) in actions.iter().enumerate() {
211 if numbered {
212 output.push_str(&format!(
213 " {}. {} {}\n",
214 i + 1,
215 action.status.indicator(),
216 action.description()
217 ));
218 } else {
219 output.push_str(&format!(
220 " {} {}\n",
221 action.status.indicator(),
222 action.description()
223 ));
224 }
225
226 if show_errors {
227 if let Some(ref err) = action.error {
228 output.push_str(&format!(" Error: {}\n", err));
229 }
230 }
231 }
232 output.push('\n');
233 }
234}
235
236#[derive(Debug, Clone)]
238pub struct Plan {
239 pub actions: Vec<PlannedAction>,
241 pub created_at: Instant,
243 pub explanation: Option<String>,
245 pub display_text: String,
247}
248
249impl Plan {
250 pub fn new(actions: Vec<AgentAction>) -> Self {
252 Self::with_explanation(None, actions)
253 }
254
255 pub fn with_explanation(explanation: Option<String>, actions: Vec<AgentAction>) -> Self {
257 let planned_actions: Vec<PlannedAction> =
258 actions.into_iter().map(PlannedAction::new).collect();
259
260 let display_text = Self::format_display_with_explanation(&explanation, &planned_actions);
261
262 Self {
263 actions: planned_actions,
264 created_at: Instant::now(),
265 explanation,
266 display_text,
267 }
268 }
269
270 fn format_display_with_explanation(
272 explanation: &Option<String>,
273 actions: &[PlannedAction],
274 ) -> String {
275 let mut output = String::new();
276
277 if let Some(exp) = explanation {
279 let trimmed = exp.trim();
280 if !trimmed.is_empty() {
281 output.push_str(trimmed);
282 output.push_str("\n\n");
283 }
284 }
285
286 let actions_text = Self::format_display_actions(actions);
288 output.push_str(&actions_text);
289 output
290 }
291
292 fn format_display_actions(actions: &[PlannedAction]) -> String {
294 if actions.is_empty() {
295 return "No actions in plan".to_string();
296 }
297
298 let mut output = String::new();
299 output.push_str("Plan: Ready to execute\n\n");
300
301 let categorized = CategorizedActions::from_actions(actions);
302 categorized.render(&mut output, true, false); output.push_str("Approve with Y, Cancel with N");
305 output
306 }
307
308 pub fn update_action_status(
310 &mut self,
311 index: usize,
312 status: ActionStatus,
313 result: Option<ActionResult>,
314 error: Option<String>,
315 ) {
316 if let Some(action) = self.actions.get_mut(index) {
317 action.status = status;
318 action.result = result;
319 action.error = error;
320 }
321 self.regenerate_display();
322 }
323
324 fn regenerate_display(&mut self) {
326 let stats = self.stats();
327 let mut output = String::new();
328
329 if stats.completed == stats.total {
331 output.push_str(&format!(
332 "Plan: Completed ({}/{})\n\n",
333 stats.completed, stats.total
334 ));
335 } else if stats.failed > 0 {
336 output.push_str(&format!(
337 "Plan: In Progress ({}/{}, {} failed)\n\n",
338 stats.completed, stats.total, stats.failed
339 ));
340 } else {
341 output.push_str(&format!(
342 "Plan: In Progress ({}/{})\n\n",
343 stats.completed, stats.total
344 ));
345 }
346
347 let categorized = CategorizedActions::from_actions(&self.actions);
349 categorized.render(&mut output, false, true);
350
351 if stats.is_complete() {
353 output.push_str("Plan: Complete");
354 } else {
355 output.push_str("Executing plan... Alt+Esc to abort");
356 }
357
358 self.display_text = output;
359 }
360
361 pub fn next_pending_action(&self) -> Option<(usize, &PlannedAction)> {
363 self.actions
364 .iter()
365 .enumerate()
366 .find(|(_, a)| a.status == ActionStatus::Pending)
367 }
368
369 pub fn stats(&self) -> PlanStats {
371 PlanStats {
372 total: self.actions.len(),
373 completed: self
374 .actions
375 .iter()
376 .filter(|a| a.status == ActionStatus::Completed)
377 .count(),
378 failed: self
379 .actions
380 .iter()
381 .filter(|a| a.status == ActionStatus::Failed)
382 .count(),
383 skipped: self
384 .actions
385 .iter()
386 .filter(|a| a.status == ActionStatus::Skipped)
387 .count(),
388 executing: self
389 .actions
390 .iter()
391 .filter(|a| a.status == ActionStatus::Executing)
392 .count(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, Copy)]
399pub struct PlanStats {
400 pub total: usize,
401 pub completed: usize,
402 pub failed: usize,
403 pub skipped: usize,
404 pub executing: usize,
405}
406
407impl PlanStats {
408 pub fn completion_percent(&self) -> u8 {
410 if self.total == 0 {
411 100
412 } else {
413 ((self.completed + self.failed + self.skipped) as f64 / self.total as f64 * 100.0) as u8
414 }
415 }
416
417 pub fn is_complete(&self) -> bool {
419 self.completed + self.failed + self.skipped == self.total
420 }
421
422 pub fn has_failures(&self) -> bool {
424 self.failed > 0
425 }
426
427 pub fn status_message(&self) -> String {
429 if self.is_complete() {
430 if self.has_failures() {
431 format!(
432 "Plan completed: {}/{} successful, {} failed",
433 self.completed, self.total, self.failed
434 )
435 } else {
436 format!("Plan completed: all {} actions successful", self.total)
437 }
438 } else {
439 format!(
440 "Plan: {} executing, {}/{} completed",
441 self.executing, self.completed, self.total
442 )
443 }
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn test_action_status_indicators() {
453 assert_eq!(ActionStatus::Pending.indicator(), "•");
454 assert_eq!(ActionStatus::Executing.indicator(), "...");
455 assert_eq!(ActionStatus::Completed.indicator(), "✓");
456 assert_eq!(ActionStatus::Failed.indicator(), "✗");
457 assert_eq!(ActionStatus::Skipped.indicator(), "-");
458 }
459
460 #[test]
461 fn test_planned_action_new() {
462 let action = AgentAction::ReadFile {
463 paths: vec!["test.txt".to_string()],
464 };
465 let planned = PlannedAction::new(action);
466 assert_eq!(planned.status, ActionStatus::Pending);
467 assert!(planned.result.is_none());
468 assert!(planned.error.is_none());
469 }
470
471 #[test]
472 fn test_plan_stats() {
473 let mut plan = Plan::new(vec![
474 AgentAction::ReadFile {
475 paths: vec!["a.txt".to_string()],
476 },
477 AgentAction::WriteFile {
478 path: "b.txt".to_string(),
479 content: "content".to_string(),
480 },
481 ]);
482
483 let mut stats = plan.stats();
484 assert_eq!(stats.total, 2);
485 assert_eq!(stats.completed, 0);
486 assert!(!stats.is_complete());
487
488 plan.update_action_status(0, ActionStatus::Completed, None, None);
489 stats = plan.stats();
490 assert_eq!(stats.completed, 1);
491 assert!(!stats.is_complete());
492
493 plan.update_action_status(1, ActionStatus::Completed, None, None);
494 stats = plan.stats();
495 assert_eq!(stats.completed, 2);
496 assert!(stats.is_complete());
497 }
498}