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