1use 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#[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
49pub struct AgentApp {
51 pub dashboard: Dashboard,
53 pub task_tree: TaskTree,
55 pub diff_viewer: DiffViewer,
57 pub review_modal: ReviewModal,
59 pub action_sender: Option<perspt_core::events::channel::ActionSender>,
61 pub active_tab: ActiveTab,
63 pub pending_request_id: Option<String>,
65 pub should_quit: bool,
67 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 pub fn new() -> Self {
90 Self::default()
91 }
92
93 pub fn set_action_sender(&mut self, sender: perspt_core::events::channel::ActionSender) {
95 self.action_sender = Some(sender);
96 }
97
98 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 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 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 KeyCode::Char('q') => self.should_quit = true,
140 KeyCode::Char('p') => self.paused = !self.paused,
142 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 KeyCode::Up | KeyCode::Char('k') => self.handle_up(),
150 KeyCode::Down | KeyCode::Char('j') => self.handle_down(),
151 KeyCode::PageUp => self.handle_page_up(),
153 KeyCode::PageDown => self.handle_page_down(),
154 KeyCode::Char(' ') | KeyCode::Enter => self.handle_select(),
156 KeyCode::Char('a') => self.show_approval_modal(),
158 _ => {}
159 }
160 }
161 }
162 Ok(())
163 }
164
165 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 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 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 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 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 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 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 if self.review_modal.visible {
375 self.review_modal.render(frame, frame.area());
376 }
377 }
378}
379
380pub 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 let (event_sender, mut event_receiver) = channel::event_channel();
390 let (action_sender, action_receiver) = channel::action_channel();
391
392 orchestrator.connect_tui(event_sender, action_receiver);
394
395 let mut terminal = ratatui::init();
397 let mut app = AgentApp::new();
398 app.set_action_sender(action_sender);
399
400 let orchestrator_handle = tokio::spawn(async move { orchestrator.run(task).await });
402
403 loop {
405 terminal.draw(|frame| app.render(frame))?;
407
408 tokio::select! {
410 _ = 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 if key.code == KeyCode::Char('q') {
417 app.should_quit = true;
418 }
419 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 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 if orchestrator_handle.is_finished() {
465 }
467 }
468
469 ratatui::restore();
470 orchestrator_handle.abort();
471 Ok(())
472}