1use crate::error::Result;
2use ratatui::{
3 backend::CrosstermBackend,
4 Terminal as RatatuiTerminal,
5 widgets::{Block, Borders, Paragraph, List, ListItem, ListState, Gauge, Table, Row, Cell},
6 layout::{Layout, Constraint, Direction, Alignment, Margin},
7 text::{Span, Line},
8 style::{Style, Color, Modifier},
9 Frame,
10};
11use crossterm::{
12 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, poll},
13 execute,
14 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
15};
16use std::{io, time::{Duration, Instant}, path::PathBuf};
17use chrono::Timelike;
18
19pub struct TuiApp {
20 should_quit: bool,
21 selected_index: usize,
22 last_update: Instant,
23}
24
25#[derive(Debug, Clone)]
26pub struct AgentActivity {
27 pub timestamp: String,
28 pub agent_name: String,
29 pub activity: String,
30 pub file_path: Option<PathBuf>,
31 pub status: AgentStatus,
32}
33
34#[derive(Debug, Clone)]
35pub enum AgentStatus {
36 Active,
37 Waiting,
38 Completed,
39 Error,
40}
41
42impl AgentStatus {
43 pub fn color(&self) -> Color {
44 match self {
45 AgentStatus::Active => Color::Green,
46 AgentStatus::Waiting => Color::Yellow,
47 AgentStatus::Completed => Color::Blue,
48 AgentStatus::Error => Color::Red,
49 }
50 }
51
52 pub fn symbol(&self) -> &'static str {
53 match self {
54 AgentStatus::Active => "๐",
55 AgentStatus::Waiting => "โณ",
56 AgentStatus::Completed => "โ
",
57 AgentStatus::Error => "โ",
58 }
59 }
60}
61
62impl TuiApp {
63 pub fn new() -> Self {
64 Self {
65 should_quit: false,
66 selected_index: 0,
67 last_update: Instant::now(),
68 }
69 }
70
71 pub fn get_selected_index(&self) -> usize {
72 self.selected_index
73 }
74
75 pub fn set_selected_index(&mut self, index: usize) {
76 self.selected_index = index;
77 }
78
79 pub fn get_last_update(&self) -> Instant {
80 self.last_update
81 }
82
83 pub fn set_last_update(&mut self, time: Instant) {
84 self.last_update = time;
85 }
86
87 pub fn should_quit(&self) -> bool {
88 self.should_quit
89 }
90
91 pub fn run(&mut self) -> Result<()> {
92 enable_raw_mode()?;
94 let mut stdout = io::stdout();
95 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
96 let backend = CrosstermBackend::new(stdout);
97 let mut terminal = RatatuiTerminal::new(backend)?;
98
99 let res = self.run_app(&mut terminal);
100
101 disable_raw_mode()?;
103 execute!(
104 terminal.backend_mut(),
105 LeaveAlternateScreen,
106 DisableMouseCapture
107 )?;
108 terminal.show_cursor()?;
109
110 res
111 }
112
113 fn run_app(&mut self, terminal: &mut RatatuiTerminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
114 let mut activities = vec![
116 AgentActivity {
117 timestamp: "14:32:15".to_string(),
118 agent_name: "Claude-Code".to_string(),
119 activity: "Analyzing code structure".to_string(),
120 file_path: Some(PathBuf::from("/project/src/main.rs")),
121 status: AgentStatus::Active,
122 },
123 AgentActivity {
124 timestamp: "14:31:42".to_string(),
125 agent_name: "Claude-Code".to_string(),
126 activity: "Refactoring function".to_string(),
127 file_path: Some(PathBuf::from("/project/src/utils.rs")),
128 status: AgentStatus::Completed,
129 },
130 AgentActivity {
131 timestamp: "14:30:18".to_string(),
132 agent_name: "Claude-Code".to_string(),
133 activity: "Waiting for user input".to_string(),
134 file_path: None,
135 status: AgentStatus::Waiting,
136 },
137 ];
138
139 loop {
140 let timeout = Duration::from_millis(100);
142 if poll(timeout)? {
143 if let Event::Key(key) = event::read()? {
144 match key.code {
145 KeyCode::Char('q') => {
146 self.should_quit = true;
147 }
148 KeyCode::Esc => {
149 self.should_quit = true;
150 }
151 KeyCode::Up => {
152 if self.selected_index > 0 {
153 self.selected_index -= 1;
154 }
155 }
156 KeyCode::Down => {
157 if self.selected_index < activities.len().saturating_sub(1) {
158 self.selected_index += 1;
159 }
160 }
161 KeyCode::Char('r') => {
162 activities.insert(0, AgentActivity {
164 timestamp: format!("{:02}:{:02}:{:02}",
165 chrono::Local::now().hour(),
166 chrono::Local::now().minute(),
167 chrono::Local::now().second()),
168 agent_name: "Claude-Code".to_string(),
169 activity: "Processing new request".to_string(),
170 file_path: Some(PathBuf::from("/project/src/new_module.rs")),
171 status: AgentStatus::Active,
172 });
173 self.selected_index = 0;
174 }
175 _ => {}
176 }
177 }
178 }
179
180 terminal.draw(|f| self.draw_agents_dashboard(f, &activities))?;
182
183 if self.should_quit {
184 break;
185 }
186 }
187
188 Ok(())
189 }
190
191 fn draw_agents_dashboard(&self, f: &mut Frame, activities: &[AgentActivity]) {
192 let chunks = Layout::default()
193 .direction(Direction::Vertical)
194 .margin(1)
195 .constraints([
196 Constraint::Length(3), Constraint::Min(8), Constraint::Length(5), Constraint::Length(3), ])
201 .split(f.size());
202
203 let header = Paragraph::new("๐ค Agent Activity Monitor")
205 .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
206 .alignment(Alignment::Center)
207 .block(Block::default().borders(Borders::ALL));
208 f.render_widget(header, chunks[0]);
209
210 let activity_items: Vec<ListItem> = activities
212 .iter()
213 .enumerate()
214 .map(|(i, activity)| {
215 let style = if i == self.selected_index {
216 Style::default().bg(Color::DarkGray)
217 } else {
218 Style::default()
219 };
220
221 let file_info = if let Some(path) = &activity.file_path {
222 format!(" ({})", path.file_name().unwrap_or_default().to_string_lossy())
223 } else {
224 String::new()
225 };
226
227 let content = format!(
228 "{} {} [{}] {}{}!",
229 activity.status.symbol(),
230 activity.timestamp,
231 activity.agent_name,
232 activity.activity,
233 file_info
234 );
235
236 ListItem::new(Line::from(Span::styled(
237 content,
238 style.fg(activity.status.color())
239 )))
240 })
241 .collect();
242
243 let activities_list = List::new(activity_items)
244 .block(Block::default()
245 .title("Recent Activity")
246 .borders(Borders::ALL))
247 .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
248 .highlight_symbol(">> ");
249
250 let mut list_state = ListState::default();
251 list_state.select(Some(self.selected_index));
252 f.render_stateful_widget(activities_list, chunks[1], &mut list_state);
253
254 let stats_chunks = Layout::default()
256 .direction(Direction::Horizontal)
257 .constraints([
258 Constraint::Percentage(33),
259 Constraint::Percentage(33),
260 Constraint::Percentage(34),
261 ])
262 .split(chunks[2]);
263
264 let active_count = activities.iter().filter(|a| matches!(a.status, AgentStatus::Active)).count();
265 let total_count = activities.len();
266 let completed_count = activities.iter().filter(|a| matches!(a.status, AgentStatus::Completed)).count();
267
268 let active_ratio = if total_count > 0 { active_count as f64 / total_count as f64 } else { 0.0 };
270 let active_gauge = Gauge::default()
271 .block(Block::default().title("Active").borders(Borders::ALL))
272 .gauge_style(Style::default().fg(Color::Green))
273 .ratio(active_ratio)
274 .label(format!("{}/{}", active_count, total_count));
275 f.render_widget(active_gauge, stats_chunks[0]);
276
277 let completion_ratio = if total_count > 0 { completed_count as f64 / total_count as f64 } else { 0.0 };
279 let completion_gauge = Gauge::default()
280 .block(Block::default().title("Completed").borders(Borders::ALL))
281 .gauge_style(Style::default().fg(Color::Blue))
282 .ratio(completion_ratio)
283 .label(format!("{:.1}%", completion_ratio * 100.0));
284 f.render_widget(completion_gauge, stats_chunks[1]);
285
286 let uptime = self.last_update.elapsed().as_secs();
288 let uptime_display = Paragraph::new(format!("{}m {}s", uptime / 60, uptime % 60))
289 .block(Block::default().title("Uptime").borders(Borders::ALL))
290 .alignment(Alignment::Center)
291 .style(Style::default().fg(Color::Yellow));
292 f.render_widget(uptime_display, stats_chunks[2]);
293
294 let help_text = "โโ: Navigate | r: Refresh | q: Quit | Esc: Exit";
296 let help = Paragraph::new(help_text)
297 .style(Style::default().fg(Color::Gray))
298 .alignment(Alignment::Center)
299 .block(Block::default().borders(Borders::ALL).title("Help"));
300 f.render_widget(help, chunks[3]);
301 }
302}
303
304pub struct AgentsDashboard;
305
306impl AgentsDashboard {
307 pub fn new() -> Self {
308 Self
309 }
310
311 pub fn run(&self) -> Result<()> {
312 let mut app = TuiApp::new();
313 app.run()
314 }
315
316 pub fn monitor_worktree(&self, worktree_path: PathBuf) -> Result<()> {
318 println!("๐ Starting agent monitoring for: {}", worktree_path.display());
319
320 let mut app = TuiApp::new();
328 app.run()
329 }
330}
331
332pub struct CleanupTui;
333
334impl CleanupTui {
335 pub fn new() -> Self {
336 Self
337 }
338
339 pub fn run(&self) -> Result<Vec<String>> {
340 use crate::git::GitRepository;
341 use crossterm::{
342 event::{self, Event, KeyCode, KeyEventKind},
343 execute,
344 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
345 };
346 use ratatui::{
347 backend::CrosstermBackend,
348 layout::{Alignment, Constraint, Direction, Layout},
349 style::{Color, Style},
350 widgets::{Block, Borders, List, ListItem, Paragraph},
351 Terminal,
352 };
353 use std::io;
354
355 let git_repo = GitRepository::find()
357 .map_err(|_| anyhow::anyhow!("Not in a git repository"))?;
358 let worktrees = git_repo.list_worktrees()?;
359 let branch_statuses = git_repo.analyze_branches_for_cleanup(&worktrees)?;
360
361 if branch_statuses.is_empty() {
362 println!("โจ No worktrees found that can be cleaned up!");
363 return Ok(vec![]);
364 }
365
366 enable_raw_mode()?;
368 let mut stdout = io::stdout();
369 execute!(stdout, EnterAlternateScreen)?;
370 let backend = CrosstermBackend::new(stdout);
371 let mut terminal = Terminal::new(backend)?;
372
373 let mut selected_index = 0;
374 let mut selected_branches: Vec<bool> = vec![false; branch_statuses.len()];
375 let mut should_quit = false;
376 let mut confirmed = false;
377
378 let result = loop {
379 terminal.draw(|f| {
380 let chunks = Layout::default()
381 .direction(Direction::Vertical)
382 .margin(1)
383 .constraints([
384 Constraint::Length(3),
385 Constraint::Min(0),
386 Constraint::Length(4),
387 ])
388 .split(f.size());
389
390 let header = Paragraph::new("๐งน Interactive Worktree Cleanup")
392 .style(Style::default().fg(Color::Yellow))
393 .alignment(Alignment::Center)
394 .block(Block::default().borders(Borders::ALL));
395 f.render_widget(header, chunks[0]);
396
397 let items: Vec<ListItem> = branch_statuses
399 .iter()
400 .enumerate()
401 .map(|(i, status)| {
402 let checkbox = if selected_branches[i] { "โ๏ธ" } else { "โ" };
403 let merged_indicator = if status.is_merged { "โ
" } else { "๐" };
404 let style = if i == selected_index {
405 Style::default().bg(Color::Blue).fg(Color::White)
406 } else {
407 Style::default()
408 };
409
410 ListItem::new(format!(
411 "{} {} {} - {}{}",
412 checkbox,
413 merged_indicator,
414 status.branch,
415 if status.has_remote { "with remote" } else { "no remote" },
416 if status.has_uncommitted_changes { " (uncommitted)" } else { "" }
417 )).style(style)
418 })
419 .collect();
420
421 let list = List::new(items)
422 .block(Block::default()
423 .borders(Borders::ALL)
424 .title("Select branches to clean up (Space to select, Enter to confirm)"));
425 f.render_widget(list, chunks[1]);
426
427 let selected_count = selected_branches.iter().filter(|&&x| x).count();
429 let footer_text = format!(
430 "โโ: Navigate | Space: Select | Enter: Confirm ({} selected) | q: Quit",
431 selected_count
432 );
433 let footer = Paragraph::new(footer_text)
434 .style(Style::default().fg(Color::Gray))
435 .alignment(Alignment::Center)
436 .block(Block::default().borders(Borders::ALL).title("Controls"));
437 f.render_widget(footer, chunks[2]);
438 })?;
439
440 if let Event::Key(key) = event::read()? {
442 if key.kind == KeyEventKind::Press {
443 match key.code {
444 KeyCode::Char('q') => {
445 should_quit = true;
446 break;
447 }
448 KeyCode::Up => {
449 if selected_index > 0 {
450 selected_index -= 1;
451 }
452 }
453 KeyCode::Down => {
454 if selected_index < branch_statuses.len() - 1 {
455 selected_index += 1;
456 }
457 }
458 KeyCode::Char(' ') => {
459 selected_branches[selected_index] = !selected_branches[selected_index];
460 }
461 KeyCode::Enter => {
462 confirmed = true;
463 break;
464 }
465 _ => {}
466 }
467 }
468 }
469 };
470
471 disable_raw_mode()?;
473 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
474 terminal.show_cursor()?;
475
476 if should_quit || !confirmed {
477 return Ok(vec![]);
478 }
479
480 let selected: Vec<String> = branch_statuses
482 .iter()
483 .enumerate()
484 .filter_map(|(i, status)| {
485 if selected_branches[i] {
486 Some(status.branch.clone())
487 } else {
488 None
489 }
490 })
491 .collect();
492
493 Ok(selected)
494 }
495}
496
497pub struct ConfigTui;
498
499impl ConfigTui {
500 pub fn new() -> Self {
501 Self
502 }
503
504 pub fn run(&self) -> Result<()> {
505 println!("โ๏ธ Configuration Editor");
506 println!("=======================");
507 println!("๐ Interactive config editor coming in v0.3.1");
508 println!("๐ก For now, use: warp config --show");
509 println!("๐ก Edit config file at: ~/.config/git-warp/config.toml");
510
511 let mut app = TuiApp::new();
513 app.run()
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 #[test]
522 fn test_tui_creation() {
523 let dashboard = AgentsDashboard::new();
524 let _cleanup_tui = CleanupTui::new();
526 let _config_tui = ConfigTui::new();
527 }
528
529 #[test]
530 fn test_agent_status() {
531 assert_eq!(AgentStatus::Active.symbol(), "๐");
532 assert_eq!(AgentStatus::Waiting.color(), Color::Yellow);
533 assert_eq!(AgentStatus::Completed.symbol(), "โ
");
534 assert_eq!(AgentStatus::Error.color(), Color::Red);
535 }
536
537 #[test]
538 fn test_agent_activity() {
539 let activity = AgentActivity {
540 timestamp: "12:34:56".to_string(),
541 agent_name: "TestAgent".to_string(),
542 activity: "Testing".to_string(),
543 file_path: Some(PathBuf::from("/test/file.rs")),
544 status: AgentStatus::Active,
545 };
546
547 assert_eq!(activity.agent_name, "TestAgent");
548 assert!(activity.file_path.is_some());
549 }
550}