1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use flow_core::{Feature, Theme};
3use flow_db::Database;
4use ratatui::Frame;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use crate::theme::TuiTheme;
10use crate::views;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum View {
14 Kanban,
15 Agents,
16 Logs,
17 Graph,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum LayoutMode {
22 Full,
23 Compact,
24 Mobile,
25}
26
27pub struct App {
28 pub current_view: View,
29 pub layout_mode: LayoutMode,
30 pub theme: Theme,
31 pub tui_theme: TuiTheme,
32 pub features: Vec<Feature>,
33 pub selected_index: usize,
34 pub show_help: bool,
35 #[allow(dead_code)]
36 pub should_quit: bool,
37 pub terminal_width: u16,
38 pub terminal_height: u16,
39 #[allow(dead_code)]
40 pub scroll_offset: usize,
41 pub log_messages: Vec<String>,
42 pub db: Option<Arc<Database>>,
43 pub db_path: Option<PathBuf>,
44 pub db_error: Option<String>,
45 pub last_refresh: Instant,
46 pub refresh_interval: Duration,
47 pub status_message: Option<(String, Instant)>,
48 pub feature_stats: Option<flow_core::FeatureStats>,
49}
50
51impl Default for App {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl App {
58 pub fn new() -> Self {
59 Self::with_db_path(None)
60 }
61
62 pub fn with_db_path(db_path: Option<PathBuf>) -> Self {
63 let theme = Theme::default();
64 let tui_theme = TuiTheme::from(&theme.colors());
65
66 let mut app = Self {
67 current_view: View::Kanban,
68 layout_mode: LayoutMode::Full,
69 theme,
70 tui_theme,
71 features: Vec::new(),
72 selected_index: 0,
73 show_help: false,
74 should_quit: false,
75 terminal_width: 120,
76 terminal_height: 30,
77 scroll_offset: 0,
78 log_messages: Vec::new(),
79 db: None,
80 db_path,
81 db_error: None,
82 last_refresh: Instant::now(),
83 refresh_interval: Duration::from_secs(2),
84 status_message: None,
85 feature_stats: None,
86 };
87
88 app.load_from_database();
89 app
90 }
91
92 pub fn render(&self, frame: &mut Frame) {
93 if self.show_help {
94 views::help::render(frame, self);
95 } else {
96 match self.current_view {
97 View::Kanban => views::kanban::render(frame, self),
98 View::Agents => views::agents::render(frame, self),
99 View::Logs => views::logs::render(frame, self),
100 View::Graph => views::graph::render(frame, self),
101 }
102 }
103 }
104
105 pub fn handle_key(&mut self, key: KeyEvent) -> bool {
106 if key.code == KeyCode::Char('q')
108 || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
109 {
110 return true;
111 }
112
113 if key.code == KeyCode::Char('?') {
115 self.show_help = !self.show_help;
116 return false;
117 }
118
119 if self.show_help {
121 self.show_help = false;
122 return false;
123 }
124
125 match key.code {
127 KeyCode::Char('1') => self.current_view = View::Kanban,
128 KeyCode::Char('2') => self.current_view = View::Agents,
129 KeyCode::Char('3') => self.current_view = View::Logs,
130 KeyCode::Char('4') => self.current_view = View::Graph,
131 KeyCode::Tab => self.next_view(),
132 _ => {}
133 }
134
135 match key.code {
137 KeyCode::Char('j') | KeyCode::Down => self.next_item(),
138 KeyCode::Char('k') | KeyCode::Up => self.prev_item(),
139 _ => {}
140 }
141
142 if key.code == KeyCode::Char('t') {
144 self.cycle_theme();
145 }
146
147 match key.code {
149 KeyCode::Enter | KeyCode::Char(' ') => self.claim_selected_feature(),
150 KeyCode::Char('p') => self.mark_selected_passing(),
151 KeyCode::Char('f') => self.mark_selected_failing(),
152 KeyCode::Char('c') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
153 self.clear_selected_in_progress();
154 }
155 KeyCode::Char('r') => self.manual_refresh(),
156 _ => {}
157 }
158
159 false
160 }
161
162 pub fn resize(&mut self, width: u16, height: u16) {
163 self.terminal_width = width;
164 self.terminal_height = height;
165
166 self.layout_mode = if width >= 120 {
168 LayoutMode::Full
169 } else if width >= 80 {
170 LayoutMode::Compact
171 } else {
172 LayoutMode::Mobile
173 };
174 }
175
176 fn next_view(&mut self) {
177 self.current_view = match self.current_view {
178 View::Kanban => View::Agents,
179 View::Agents => View::Logs,
180 View::Logs => View::Graph,
181 View::Graph => View::Kanban,
182 };
183 }
184
185 fn next_item(&mut self) {
186 if !self.features.is_empty() {
187 self.selected_index = (self.selected_index + 1) % self.features.len();
188 }
189 }
190
191 fn prev_item(&mut self) {
192 if !self.features.is_empty() {
193 self.selected_index = if self.selected_index == 0 {
194 self.features.len() - 1
195 } else {
196 self.selected_index - 1
197 };
198 }
199 }
200
201 fn cycle_theme(&mut self) {
202 self.theme = self.theme.next();
203 self.tui_theme = TuiTheme::from(&self.theme.colors());
204 self.add_log(format!("Theme changed to: {}", self.theme.name()));
205 }
206
207 pub fn add_log(&mut self, message: String) {
208 self.log_messages.push(message);
209 if self.log_messages.len() > 100 {
211 self.log_messages.remove(0);
212 }
213 }
214
215 pub fn set_status(&mut self, message: String) {
216 self.status_message = Some((message, Instant::now()));
217 }
218
219 pub fn get_status_message(&self) -> Option<&str> {
220 if let Some((msg, timestamp)) = &self.status_message {
221 if timestamp.elapsed() < Duration::from_secs(3) {
223 return Some(msg.as_str());
224 }
225 }
226 None
227 }
228
229 pub fn should_refresh(&self) -> bool {
230 self.last_refresh.elapsed() >= self.refresh_interval
231 }
232
233 pub fn auto_refresh(&mut self) {
234 if self.should_refresh() {
235 self.load_from_database();
236 }
237 }
238
239 pub fn load_from_database(&mut self) {
240 self.last_refresh = Instant::now();
241
242 let db_path = if let Some(ref path) = self.db_path {
244 path.clone()
245 } else {
246 let home = match std::env::var("HOME") {
248 Ok(h) => PathBuf::from(h),
249 Err(_) => {
250 self.db_error = Some("HOME environment variable not set".to_string());
251 self.load_demo_data();
252 return;
253 }
254 };
255 home.join(".agent-flow").join("flow.db")
256 };
257
258 match Database::open(&db_path) {
260 Ok(db) => {
261 let db = Arc::new(db);
262 self.db = Some(Arc::clone(&db));
263 self.db_path = Some(db_path.clone());
264 self.db_error = None;
265
266 if let Ok(conn) = db.writer().lock() {
268 match flow_db::FeatureStore::get_all(&conn) {
269 Ok(features) => {
270 self.features = features;
271 if self.selected_index >= self.features.len()
272 && !self.features.is_empty()
273 {
274 self.selected_index = self.features.len() - 1;
275 }
276 }
277 Err(e) => {
278 self.db_error = Some(format!("Failed to load features: {e}"));
279 self.add_log(format!("Error loading features: {e}"));
280 }
281 }
282
283 match flow_db::FeatureStore::get_stats(&conn) {
285 Ok(stats) => {
286 self.feature_stats = Some(stats);
287 }
288 Err(e) => {
289 self.add_log(format!("Error loading stats: {e}"));
290 }
291 }
292 } else {
293 self.db_error = Some("Failed to acquire database lock".to_string());
294 }
295
296 self.add_log(format!(
297 "Loaded {} features from database",
298 self.features.len()
299 ));
300 }
301 Err(e) => {
302 self.db_error = Some(format!("Failed to open database: {e}"));
303 self.add_log("No database found. Run 'flow init' to create one, or 'flow features add' to add features.".to_string());
304 self.load_demo_data();
305 }
306 }
307 }
308
309 fn manual_refresh(&mut self) {
310 self.load_from_database();
311 self.set_status("✓ Refreshed from database".to_string());
312 }
313
314 fn claim_selected_feature(&mut self) {
315 if self.features.is_empty() {
316 return;
317 }
318
319 let feature_id = self.features[self.selected_index].id;
320 let feature_name = self.features[self.selected_index].name.clone();
321
322 let db = match &self.db {
323 Some(db) => Arc::clone(db),
324 None => return,
325 };
326
327 let result = {
328 if let Ok(conn) = db.writer().lock() {
329 flow_db::FeatureStore::claim_and_get(&conn, feature_id)
330 } else {
331 return;
332 }
333 };
334
335 match result {
336 Ok(_) => {
337 self.set_status(format!("✓ Claimed \"{}\" (#{feature_id})", feature_name));
338 self.add_log(format!("Claimed feature #{feature_id}: {feature_name}"));
339 self.load_from_database();
340 }
341 Err(e) => {
342 self.set_status(format!("✗ Failed to claim: {e}"));
343 self.add_log(format!("Failed to claim feature #{feature_id}: {e}"));
344 }
345 }
346 }
347
348 fn mark_selected_passing(&mut self) {
349 if self.features.is_empty() {
350 return;
351 }
352
353 let feature_id = self.features[self.selected_index].id;
354 let feature_name = self.features[self.selected_index].name.clone();
355
356 let db = match &self.db {
357 Some(db) => Arc::clone(db),
358 None => return,
359 };
360
361 let result = {
362 if let Ok(conn) = db.writer().lock() {
363 flow_db::FeatureStore::mark_passing(&conn, feature_id)
364 } else {
365 return;
366 }
367 };
368
369 match result {
370 Ok(()) => {
371 self.set_status(format!("✓ Marked \"{}\" as passing", feature_name));
372 self.add_log(format!("Marked feature #{feature_id} as passing"));
373 self.load_from_database();
374 }
375 Err(e) => {
376 self.set_status(format!("✗ Failed to mark passing: {e}"));
377 self.add_log(format!(
378 "Failed to mark feature #{feature_id} as passing: {e}"
379 ));
380 }
381 }
382 }
383
384 fn mark_selected_failing(&mut self) {
385 if self.features.is_empty() {
386 return;
387 }
388
389 let feature_id = self.features[self.selected_index].id;
390 let feature_name = self.features[self.selected_index].name.clone();
391
392 let db = match &self.db {
393 Some(db) => Arc::clone(db),
394 None => return,
395 };
396
397 let result = {
398 if let Ok(conn) = db.writer().lock() {
399 flow_db::FeatureStore::mark_failing(&conn, feature_id)
400 } else {
401 return;
402 }
403 };
404
405 match result {
406 Ok(()) => {
407 self.set_status(format!("✓ Marked \"{}\" as failing", feature_name));
408 self.add_log(format!("Marked feature #{feature_id} as failing"));
409 self.load_from_database();
410 }
411 Err(e) => {
412 self.set_status(format!("✗ Failed to mark failing: {e}"));
413 self.add_log(format!(
414 "Failed to mark feature #{feature_id} as failing: {e}"
415 ));
416 }
417 }
418 }
419
420 fn clear_selected_in_progress(&mut self) {
421 if self.features.is_empty() {
422 return;
423 }
424
425 let feature_id = self.features[self.selected_index].id;
426 let feature_name = self.features[self.selected_index].name.clone();
427
428 let db = match &self.db {
429 Some(db) => Arc::clone(db),
430 None => return,
431 };
432
433 let result = {
434 if let Ok(conn) = db.writer().lock() {
435 flow_db::FeatureStore::clear_in_progress(&conn, feature_id)
436 } else {
437 return;
438 }
439 };
440
441 match result {
442 Ok(()) => {
443 self.set_status(format!("✓ Cleared in-progress for \"{}\"", feature_name));
444 self.add_log(format!("Cleared in-progress for feature #{feature_id}"));
445 self.load_from_database();
446 }
447 Err(e) => {
448 self.set_status(format!("✗ Failed to clear in-progress: {e}"));
449 self.add_log(format!(
450 "Failed to clear in-progress for feature #{feature_id}: {e}"
451 ));
452 }
453 }
454 }
455
456 #[allow(clippy::too_many_lines)]
457 pub fn load_demo_data(&mut self) {
458 self.features = vec![
460 Feature {
461 id: 1,
462 priority: 100,
463 category: "Backend".to_string(),
464 name: "User Authentication".to_string(),
465 description: "Implement JWT-based authentication system".to_string(),
466 steps: vec![
467 "Create user model".to_string(),
468 "Implement JWT tokens".to_string(),
469 "Add login/logout endpoints".to_string(),
470 ],
471 passes: false,
472 in_progress: false,
473 dependencies: vec![],
474 created_at: Some("2024-01-15T10:00:00Z".to_string()),
475 updated_at: Some("2024-01-15T10:00:00Z".to_string()),
476 },
477 Feature {
478 id: 2,
479 priority: 90,
480 category: "Frontend".to_string(),
481 name: "Login Page UI".to_string(),
482 description: "Create responsive login page".to_string(),
483 steps: vec![
484 "Design mockup".to_string(),
485 "Implement form".to_string(),
486 "Add validation".to_string(),
487 ],
488 passes: false,
489 in_progress: false,
490 dependencies: vec![],
491 created_at: Some("2024-01-15T11:00:00Z".to_string()),
492 updated_at: Some("2024-01-15T11:00:00Z".to_string()),
493 },
494 Feature {
495 id: 3,
496 priority: 80,
497 category: "Backend".to_string(),
498 name: "API Rate Limiting".to_string(),
499 description: "Add rate limiting middleware".to_string(),
500 steps: vec![
501 "Research solutions".to_string(),
502 "Implement middleware".to_string(),
503 ],
504 passes: false,
505 in_progress: true,
506 dependencies: vec![1],
507 created_at: Some("2024-01-16T09:00:00Z".to_string()),
508 updated_at: Some("2024-01-16T14:30:00Z".to_string()),
509 },
510 Feature {
511 id: 4,
512 priority: 70,
513 category: "Testing".to_string(),
514 name: "Integration Tests".to_string(),
515 description: "Write comprehensive test suite".to_string(),
516 steps: vec![
517 "Setup test framework".to_string(),
518 "Write API tests".to_string(),
519 "Add CI/CD integration".to_string(),
520 ],
521 passes: false,
522 in_progress: true,
523 dependencies: vec![1, 3],
524 created_at: Some("2024-01-16T10:00:00Z".to_string()),
525 updated_at: Some("2024-01-16T15:00:00Z".to_string()),
526 },
527 Feature {
528 id: 5,
529 priority: 60,
530 category: "Frontend".to_string(),
531 name: "Dashboard".to_string(),
532 description: "User dashboard with stats".to_string(),
533 steps: vec![
534 "Create layout".to_string(),
535 "Add charts".to_string(),
536 "Implement data fetching".to_string(),
537 ],
538 passes: true,
539 in_progress: false,
540 dependencies: vec![1, 2],
541 created_at: Some("2024-01-14T08:00:00Z".to_string()),
542 updated_at: Some("2024-01-15T17:00:00Z".to_string()),
543 },
544 Feature {
545 id: 6,
546 priority: 50,
547 category: "Backend".to_string(),
548 name: "Email Notifications".to_string(),
549 description: "Send email notifications for events".to_string(),
550 steps: vec![
551 "Setup email service".to_string(),
552 "Create templates".to_string(),
553 ],
554 passes: true,
555 in_progress: false,
556 dependencies: vec![1],
557 created_at: Some("2024-01-13T14:00:00Z".to_string()),
558 updated_at: Some("2024-01-14T16:00:00Z".to_string()),
559 },
560 Feature {
561 id: 7,
562 priority: 40,
563 category: "DevOps".to_string(),
564 name: "Docker Setup".to_string(),
565 description: "Containerize application".to_string(),
566 steps: vec![
567 "Create Dockerfile".to_string(),
568 "Setup docker-compose".to_string(),
569 ],
570 passes: true,
571 in_progress: false,
572 dependencies: vec![],
573 created_at: Some("2024-01-12T09:00:00Z".to_string()),
574 updated_at: Some("2024-01-13T11:00:00Z".to_string()),
575 },
576 Feature {
577 id: 8,
578 priority: 30,
579 category: "Frontend".to_string(),
580 name: "Settings Page".to_string(),
581 description: "User settings and preferences".to_string(),
582 steps: vec![
583 "Design UI".to_string(),
584 "Implement form handling".to_string(),
585 ],
586 passes: true,
587 in_progress: false,
588 dependencies: vec![1, 2],
589 created_at: Some("2024-01-11T10:00:00Z".to_string()),
590 updated_at: Some("2024-01-12T15:00:00Z".to_string()),
591 },
592 Feature {
593 id: 9,
594 priority: 20,
595 category: "Documentation".to_string(),
596 name: "API Documentation".to_string(),
597 description: "Complete OpenAPI specs".to_string(),
598 steps: vec!["Write specs".to_string(), "Generate docs".to_string()],
599 passes: true,
600 in_progress: false,
601 dependencies: vec![1, 3],
602 created_at: Some("2024-01-10T13:00:00Z".to_string()),
603 updated_at: Some("2024-01-11T16:00:00Z".to_string()),
604 },
605 Feature {
606 id: 10,
607 priority: 10,
608 category: "Security".to_string(),
609 name: "Security Audit".to_string(),
610 description: "Comprehensive security review".to_string(),
611 steps: vec![
612 "Run vulnerability scan".to_string(),
613 "Review dependencies".to_string(),
614 "Fix issues".to_string(),
615 ],
616 passes: true,
617 in_progress: false,
618 dependencies: vec![1, 3, 6],
619 created_at: Some("2024-01-09T08:00:00Z".to_string()),
620 updated_at: Some("2024-01-10T18:00:00Z".to_string()),
621 },
622 ];
623
624 self.add_log("Loaded 10 demo features".to_string());
625 self.add_log(format!("Current theme: {}", self.theme.name()));
626 self.add_log("Press ? for help".to_string());
627 }
628}