perspt_tui/
agent_app.rs

1//! Agent App - Main TUI Application
2//!
3//! Coordinates all TUI components for the Agent mode with full keyboard navigation.
4//! Now with async event-driven architecture support.
5
6use crate::app_event::{AgentStateUpdate, AppEvent};
7use crate::dashboard::Dashboard;
8use crate::diff_viewer::DiffViewer;
9use crate::review_modal::{ReviewDecision, ReviewModal};
10use crate::task_tree::{TaskStatus, TaskTree};
11use crossterm::event::{KeyCode, KeyEventKind};
12use perspt_core::AgentEvent;
13use ratatui::{
14    crossterm::event::{self, Event},
15    layout::{Constraint, Direction, Layout},
16    style::{Color, Modifier, Style},
17    widgets::{Block, Borders, Tabs},
18    DefaultTerminal, Frame,
19};
20use std::io;
21
22/// Active tab
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ActiveTab {
25    Dashboard,
26    Tasks,
27    Diff,
28}
29
30impl ActiveTab {
31    fn index(&self) -> usize {
32        match self {
33            ActiveTab::Dashboard => 0,
34            ActiveTab::Tasks => 1,
35            ActiveTab::Diff => 2,
36        }
37    }
38
39    #[allow(dead_code)]
40    fn from_index(i: usize) -> Self {
41        match i {
42            0 => ActiveTab::Dashboard,
43            1 => ActiveTab::Tasks,
44            _ => ActiveTab::Diff,
45        }
46    }
47}
48
49/// Agent app state
50pub struct AgentApp {
51    /// Dashboard component
52    pub dashboard: Dashboard,
53    /// Task tree component
54    pub task_tree: TaskTree,
55    /// Diff viewer component
56    pub diff_viewer: DiffViewer,
57    /// Review modal component
58    pub review_modal: ReviewModal,
59    /// Sender for action feedback to orchestrator
60    pub action_sender: Option<perspt_core::events::channel::ActionSender>,
61    /// Active tab
62    pub active_tab: ActiveTab,
63    /// Pending approval request ID
64    pub pending_request_id: Option<String>,
65    /// Should quit
66    pub should_quit: bool,
67    /// Is paused
68    pub paused: bool,
69}
70
71impl Default for AgentApp {
72    fn default() -> Self {
73        Self {
74            active_tab: ActiveTab::Dashboard,
75            dashboard: Dashboard::new(),
76            task_tree: TaskTree::new(),
77            diff_viewer: DiffViewer::new(),
78            review_modal: ReviewModal::new(),
79            action_sender: None,
80            pending_request_id: None,
81            should_quit: false,
82            paused: false,
83        }
84    }
85}
86
87impl AgentApp {
88    /// Create a new agent app
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Set action sender
94    pub fn set_action_sender(&mut self, sender: perspt_core::events::channel::ActionSender) {
95        self.action_sender = Some(sender);
96    }
97
98    /// Run the app main loop
99    pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
100        while !self.should_quit {
101            terminal.draw(|frame| self.render(frame))?;
102            self.handle_events()?;
103        }
104        Ok(())
105    }
106
107    /// Handle input events
108    fn handle_events(&mut self) -> io::Result<()> {
109        if event::poll(std::time::Duration::from_millis(100))? {
110            if let Event::Key(key) = event::read()? {
111                if key.kind != KeyEventKind::Press {
112                    return Ok(());
113                }
114
115                // Handle modal first if visible
116                if self.review_modal.visible {
117                    match key.code {
118                        KeyCode::Left => self.review_modal.select_left(),
119                        KeyCode::Right => self.review_modal.select_right(),
120                        KeyCode::Char(c) => {
121                            if let Some(decision) = self.review_modal.handle_key(c) {
122                                self.handle_review_decision(decision);
123                                self.review_modal.hide();
124                            }
125                        }
126                        KeyCode::Enter => {
127                            let decision = self.review_modal.get_decision();
128                            self.handle_review_decision(decision);
129                            self.review_modal.hide();
130                        }
131                        KeyCode::Esc => self.review_modal.hide(),
132                        _ => {}
133                    }
134                    return Ok(());
135                }
136
137                match key.code {
138                    // Quit
139                    KeyCode::Char('q') => self.should_quit = true,
140                    // Pause/Resume
141                    KeyCode::Char('p') => self.paused = !self.paused,
142                    // Tab navigation
143                    KeyCode::Tab => self.next_tab(),
144                    KeyCode::BackTab => self.prev_tab(),
145                    KeyCode::Char('1') => self.active_tab = ActiveTab::Dashboard,
146                    KeyCode::Char('2') => self.active_tab = ActiveTab::Tasks,
147                    KeyCode::Char('3') => self.active_tab = ActiveTab::Diff,
148                    // Vertical navigation (vim-style)
149                    KeyCode::Up | KeyCode::Char('k') => self.handle_up(),
150                    KeyCode::Down | KeyCode::Char('j') => self.handle_down(),
151                    // Page navigation
152                    KeyCode::PageUp => self.handle_page_up(),
153                    KeyCode::PageDown => self.handle_page_down(),
154                    // Task tree specific
155                    KeyCode::Char(' ') | KeyCode::Enter => self.handle_select(),
156                    // Approve current
157                    KeyCode::Char('a') => self.show_approval_modal(),
158                    _ => {}
159                }
160            }
161        }
162        Ok(())
163    }
164
165    /// Handle logical app events
166    pub fn handle_app_event(&mut self, event: AppEvent) {
167        match event {
168            AppEvent::CoreEvent(core_event) => self.handle_core_event(core_event),
169            AppEvent::AgentUpdate(update) => self.handle_agent_update(update),
170            _ => {}
171        }
172    }
173
174    /// Handle events from the SRBN Orchestrator
175    fn handle_core_event(&mut self, event: AgentEvent) {
176        match event {
177            AgentEvent::PlanGenerated(plan) => {
178                self.dashboard
179                    .log(format!("Plan generated with {} tasks", plan.tasks.len()));
180                self.task_tree.populate_from_plan(plan.clone());
181            }
182            AgentEvent::TaskStatusChanged { node_id, status } => {
183                self.task_tree.update_status(&node_id, status.into());
184                self.dashboard
185                    .log(format!("🔄 Task {} -> {:?}", node_id, status));
186            }
187            AgentEvent::Log(message) => {
188                self.dashboard.log(message);
189            }
190            AgentEvent::NodeCompleted { node_id, goal } => {
191                self.task_tree
192                    .update_status(&node_id, TaskStatus::Completed);
193                self.dashboard.log(format!("✓ {} - {}", node_id, goal));
194            }
195            AgentEvent::ApprovalRequest {
196                request_id,
197                node_id,
198                action_type,
199                description,
200                diff: _,
201            } => {
202                self.pending_request_id = Some(request_id);
203                // Map ActionType to a set of files or something similar for the modal
204                let files = match action_type {
205                    perspt_core::ActionType::FileWrite { path } => vec![path],
206                    _ => vec![],
207                };
208                self.review_modal
209                    .show(format!("Approval: {}", node_id), description, files);
210            }
211            AgentEvent::Complete { success, message } => {
212                let emoji = if success { "🎉" } else { "❌" };
213                self.dashboard
214                    .log(format!("{} Session Complete: {}", emoji, message));
215            }
216            _ => {}
217        }
218    }
219
220    fn handle_review_decision(&mut self, decision: ReviewDecision) {
221        let request_id = self.pending_request_id.take();
222
223        match decision {
224            ReviewDecision::Approve => {
225                self.dashboard.log("✓ Changes approved".to_string());
226                if let (Some(sender), Some(rid)) = (&self.action_sender, request_id) {
227                    let _ = sender.send(perspt_core::AgentAction::Approve { request_id: rid });
228                }
229            }
230            ReviewDecision::Reject => {
231                self.dashboard.log("✗ Changes rejected".to_string());
232                if let (Some(sender), Some(rid)) = (&self.action_sender, request_id) {
233                    let _ = sender.send(perspt_core::AgentAction::Reject {
234                        request_id: rid,
235                        reason: Some("User rejected in TUI".to_string()),
236                    });
237                }
238            }
239            ReviewDecision::Edit => {
240                self.dashboard.log("📝 Opening in editor...".to_string());
241            }
242            ReviewDecision::ViewDiff => {
243                self.active_tab = ActiveTab::Diff;
244            }
245            ReviewDecision::Skip => {
246                self.dashboard.log("⏭ Skipped review".to_string());
247            }
248        }
249    }
250
251    fn handle_agent_update(&mut self, update: AgentStateUpdate) {
252        match update {
253            AgentStateUpdate::Energy { node_id, energy } => {
254                self.dashboard.update_energy(energy);
255                self.dashboard.current_node = Some(node_id.clone());
256                self.task_tree.update_energy(&node_id, energy);
257            }
258            AgentStateUpdate::Status { node_id, status } => {
259                self.task_tree.update_status(&node_id, status);
260            }
261            AgentStateUpdate::Log(msg) => {
262                self.dashboard.log(msg);
263            }
264            AgentStateUpdate::NodeCompleted(node_id) => {
265                self.dashboard.log(format!("Node {} completed", node_id));
266            }
267            AgentStateUpdate::Complete => {
268                self.dashboard.log("Orchestration complete".to_string());
269                self.dashboard.status = "Complete".to_string();
270            }
271        }
272    }
273
274    fn next_tab(&mut self) {
275        self.active_tab = match self.active_tab {
276            ActiveTab::Dashboard => ActiveTab::Tasks,
277            ActiveTab::Tasks => ActiveTab::Diff,
278            ActiveTab::Diff => ActiveTab::Dashboard,
279        };
280    }
281
282    fn prev_tab(&mut self) {
283        self.active_tab = match self.active_tab {
284            ActiveTab::Dashboard => ActiveTab::Diff,
285            ActiveTab::Tasks => ActiveTab::Dashboard,
286            ActiveTab::Diff => ActiveTab::Tasks,
287        };
288    }
289
290    fn handle_up(&mut self) {
291        match self.active_tab {
292            ActiveTab::Tasks => self.task_tree.previous(),
293            ActiveTab::Diff => self.diff_viewer.scroll_up(),
294            _ => {}
295        }
296    }
297
298    fn handle_down(&mut self) {
299        match self.active_tab {
300            ActiveTab::Tasks => self.task_tree.next(),
301            ActiveTab::Diff => self.diff_viewer.scroll_down(),
302            _ => {}
303        }
304    }
305
306    fn handle_page_up(&mut self) {
307        if self.active_tab == ActiveTab::Diff {
308            self.diff_viewer.page_up(20);
309        }
310    }
311
312    fn handle_page_down(&mut self) {
313        if self.active_tab == ActiveTab::Diff {
314            self.diff_viewer.page_down(20);
315        }
316    }
317
318    fn handle_select(&mut self) {
319        if self.active_tab == ActiveTab::Tasks {
320            if let Some(node) = self.task_tree.selected_task() {
321                self.dashboard.log(format!("Selected: {}", node.id));
322            }
323        }
324    }
325
326    fn show_approval_modal(&mut self) {
327        // Placeholder for manual approval trigger if needed
328        self.dashboard
329            .log("Manual approval modal Not Implemented".to_string());
330    }
331
332    pub fn handle_terminal_event(&mut self, event: crossterm::event::Event) -> bool {
333        // Legacy bridge for run_agent_tui_with_orchestrator
334        if let crossterm::event::Event::Key(key) = event {
335            if key.code == KeyCode::Char('q') {
336                return false;
337            }
338        }
339        true
340    }
341
342    pub fn render(&mut self, frame: &mut Frame) {
343        let chunks = Layout::default()
344            .direction(Direction::Vertical)
345            .constraints([Constraint::Length(3), Constraint::Min(0)])
346            .split(frame.area());
347
348        // Header with Tabs
349        let titles = vec!["[1] Dashboard", "[2] Task Tree", "[3] Diff Viewer"];
350        let tabs = Tabs::new(titles)
351            .block(
352                Block::default()
353                    .borders(Borders::ALL)
354                    .title(" perspt Agent mode "),
355            )
356            .select(self.active_tab.index())
357            .style(Style::default().fg(Color::Cyan))
358            .highlight_style(
359                Style::default()
360                    .add_modifier(Modifier::BOLD)
361                    .bg(Color::Black)
362                    .fg(Color::Yellow),
363            );
364        frame.render_widget(tabs, chunks[0]);
365
366        // Main Content
367        match self.active_tab {
368            ActiveTab::Dashboard => self.dashboard.render(frame, chunks[1]),
369            ActiveTab::Tasks => self.task_tree.render(frame, chunks[1]),
370            ActiveTab::Diff => self.diff_viewer.render(frame, chunks[1]),
371        }
372
373        // Modals
374        if self.review_modal.visible {
375            self.review_modal.render(frame, frame.area());
376        }
377    }
378}
379
380/// Run the agent TUI with a real SRBNOrchestrator
381pub async fn run_agent_tui_with_orchestrator(
382    mut orchestrator: perspt_agent::SRBNOrchestrator,
383    task: String,
384) -> anyhow::Result<()> {
385    use crate::app_event::AppEvent;
386    use perspt_core::events::channel;
387
388    // Create channels for bidirectional communication
389    let (event_sender, mut event_receiver) = channel::event_channel();
390    let (action_sender, action_receiver) = channel::action_channel();
391
392    // Connect orchestrator to TUI
393    orchestrator.connect_tui(event_sender, action_receiver);
394
395    // Initializing terminal
396    let mut terminal = ratatui::init();
397    let mut app = AgentApp::new();
398    app.set_action_sender(action_sender);
399
400    // Spawn orchestrator in background task
401    let orchestrator_handle = tokio::spawn(async move { orchestrator.run(task).await });
402
403    // Main event loop
404    loop {
405        // Render
406        terminal.draw(|frame| app.render(frame))?;
407
408        // Handle events with timeout for responsiveness
409        tokio::select! {
410            // Terminal events
411            _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
412                if crossterm::event::poll(std::time::Duration::from_millis(0))? {
413                    if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
414                        if key.kind == crossterm::event::KeyEventKind::Press {
415                            // Map Key Events to app state
416                            if key.code == KeyCode::Char('q') {
417                                app.should_quit = true;
418                            }
419                            // Pass keys to modal if visible
420                            if app.review_modal.visible {
421                                match key.code {
422                                    KeyCode::Left => app.review_modal.select_left(),
423                                    KeyCode::Right => app.review_modal.select_right(),
424                                    KeyCode::Char(c) => {
425                                        if let Some(decision) = app.review_modal.handle_key(c) {
426                                            app.handle_review_decision(decision);
427                                            app.review_modal.hide();
428                                        }
429                                    }
430                                    KeyCode::Enter => {
431                                        let decision = app.review_modal.get_decision();
432                                        app.handle_review_decision(decision);
433                                        app.review_modal.hide();
434                                    }
435                                    KeyCode::Esc => app.review_modal.hide(),
436                                    _ => {}
437                                }
438                            } else {
439                                match key.code {
440                                    KeyCode::Tab => app.next_tab(),
441                                    KeyCode::Char('1') => app.active_tab = ActiveTab::Dashboard,
442                                    KeyCode::Char('2') => app.active_tab = ActiveTab::Tasks,
443                                    KeyCode::Char('3') => app.active_tab = ActiveTab::Diff,
444                                    KeyCode::Up | KeyCode::Char('k') => app.handle_up(),
445                                    KeyCode::Down | KeyCode::Char('j') => app.handle_down(),
446                                    _ => {}
447                                }
448                            }
449                        }
450                    }
451                }
452            }
453            // Orchestrator events
454            Some(event) = event_receiver.recv() => {
455                app.handle_app_event(AppEvent::CoreEvent(event));
456            }
457        }
458
459        if app.should_quit {
460            break;
461        }
462
463        // Check if orchestrator finished
464        if orchestrator_handle.is_finished() {
465            // app.dashboard.log("🏁 Orchestrator finished".to_string());
466        }
467    }
468
469    ratatui::restore();
470    orchestrator_handle.abort();
471    Ok(())
472}