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