4runr-os 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,676 +1,676 @@
1
- /// Run Manager Screen
2
- /// View, filter, and manage agent runs
3
-
4
- use ratatui::prelude::*;
5
- use ratatui::widgets::{Block, Borders, Paragraph, Wrap, List, ListItem};
6
- use crate::app::AppState;
7
-
8
- // === 4RUNR BRAND COLORS (matching layout.rs) ===
9
- const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
- const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
11
- const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
12
- const AMBER_WARN: Color = Color::Rgb(255, 191, 0);
13
- const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
14
- const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
15
- const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
16
- const BG_PANEL: Color = Color::Rgb(18, 18, 25);
17
-
18
- /// Run Manager state
19
- #[derive(Debug, Clone)]
20
- pub struct RunManagerState {
21
- /// List of runs
22
- pub runs: Vec<RunInfo>,
23
-
24
- /// Selected run index
25
- pub selected_index: usize,
26
-
27
- /// Filter state
28
- pub filter: RunFilter,
29
-
30
- /// Sort state
31
- pub sort: RunSort,
32
-
33
- /// Loading state
34
- pub loading: bool,
35
-
36
- /// Last refresh time
37
- pub last_refresh: Option<std::time::Instant>,
38
-
39
- /// Detailed view state (None = list view, Some(index) = detail view)
40
- pub detail_view: Option<usize>,
41
- }
42
-
43
- #[derive(Debug, Clone)]
44
- pub struct RunInfo {
45
- pub id: String,
46
- pub name: String,
47
- pub agent_name: String,
48
- pub status: RunStatus,
49
- pub started_at: String,
50
- pub duration: Option<String>,
51
- pub progress: Option<f32>, // 0.0-1.0
52
-
53
- // Extended details
54
- pub input_tokens: Option<u32>,
55
- pub output_tokens: Option<u32>,
56
- pub total_cost: Option<f64>,
57
- pub model: Option<String>,
58
- pub error_message: Option<String>,
59
- pub logs: Vec<String>,
60
- }
61
-
62
- #[derive(Debug, Clone, PartialEq)]
63
- pub enum RunStatus {
64
- Pending,
65
- Running,
66
- Completed,
67
- Failed,
68
- Cancelled,
69
- }
70
-
71
- impl RunStatus {
72
- pub fn as_str(&self) -> &str {
73
- match self {
74
- RunStatus::Pending => "PENDING",
75
- RunStatus::Running => "RUNNING",
76
- RunStatus::Completed => "COMPLETED",
77
- RunStatus::Failed => "FAILED",
78
- RunStatus::Cancelled => "CANCELLED",
79
- }
80
- }
81
-
82
- pub fn color(&self) -> Color {
83
- match self {
84
- RunStatus::Pending => AMBER_WARN,
85
- RunStatus::Running => CYBER_CYAN,
86
- RunStatus::Completed => NEON_GREEN,
87
- RunStatus::Failed => Color::Rgb(255, 69, 69), // Bright red
88
- RunStatus::Cancelled => TEXT_DIM,
89
- }
90
- }
91
- }
92
-
93
- #[derive(Debug, Clone, PartialEq)]
94
- pub enum RunFilter {
95
- All,
96
- Active, // Pending + Running
97
- Completed,
98
- Failed,
99
- }
100
-
101
- impl RunFilter {
102
- pub fn as_str(&self) -> &str {
103
- match self {
104
- RunFilter::All => "All",
105
- RunFilter::Active => "Active",
106
- RunFilter::Completed => "Completed",
107
- RunFilter::Failed => "Failed",
108
- }
109
- }
110
-
111
- pub fn matches(&self, status: &RunStatus) -> bool {
112
- match self {
113
- RunFilter::All => true,
114
- RunFilter::Active => matches!(status, RunStatus::Pending | RunStatus::Running),
115
- RunFilter::Completed => matches!(status, RunStatus::Completed),
116
- RunFilter::Failed => matches!(status, RunStatus::Failed),
117
- }
118
- }
119
- }
120
-
121
- #[derive(Debug, Clone, PartialEq)]
122
- pub enum RunSort {
123
- DateDesc, // Newest first
124
- DateAsc, // Oldest first
125
- Status,
126
- Agent,
127
- }
128
-
129
- impl RunSort {
130
- pub fn as_str(&self) -> &str {
131
- match self {
132
- RunSort::DateDesc => "Date ↓",
133
- RunSort::DateAsc => "Date ↑",
134
- RunSort::Status => "Status",
135
- RunSort::Agent => "Agent",
136
- }
137
- }
138
- }
139
-
140
- impl Default for RunManagerState {
141
- fn default() -> Self {
142
- Self {
143
- runs: vec![
144
- // Mock data for testing
145
- RunInfo {
146
- id: "run-001".to_string(),
147
- name: "Data Analysis Task".to_string(),
148
- agent_name: "data-analyst".to_string(),
149
- status: RunStatus::Running,
150
- started_at: "2m ago".to_string(),
151
- duration: Some("2m 15s".to_string()),
152
- progress: Some(0.65),
153
- input_tokens: Some(1250),
154
- output_tokens: Some(850),
155
- total_cost: Some(0.0315),
156
- model: Some("gpt-4-turbo".to_string()),
157
- error_message: None,
158
- logs: vec![
159
- "[00:00] Run started".to_string(),
160
- "[00:15] Loading data from source...".to_string(),
161
- "[00:45] Processing 1,234 records...".to_string(),
162
- "[01:30] Analyzing patterns...".to_string(),
163
- "[02:15] Generating insights...".to_string(),
164
- ],
165
- },
166
- RunInfo {
167
- id: "run-002".to_string(),
168
- name: "Code Review".to_string(),
169
- agent_name: "code-reviewer".to_string(),
170
- status: RunStatus::Completed,
171
- started_at: "15m ago".to_string(),
172
- duration: Some("3m 42s".to_string()),
173
- progress: Some(1.0),
174
- input_tokens: Some(3200),
175
- output_tokens: Some(1800),
176
- total_cost: Some(0.075),
177
- model: Some("gpt-4".to_string()),
178
- error_message: None,
179
- logs: vec![
180
- "[00:00] Run started".to_string(),
181
- "[00:30] Analyzing code structure...".to_string(),
182
- "[01:45] Checking for issues...".to_string(),
183
- "[02:30] Generating recommendations...".to_string(),
184
- "[03:42] Review complete".to_string(),
185
- ],
186
- },
187
- RunInfo {
188
- id: "run-003".to_string(),
189
- name: "Document Generation".to_string(),
190
- agent_name: "writer".to_string(),
191
- status: RunStatus::Failed,
192
- started_at: "1h ago".to_string(),
193
- duration: Some("1m 05s".to_string()),
194
- progress: Some(0.25),
195
- input_tokens: Some(450),
196
- output_tokens: Some(120),
197
- total_cost: Some(0.0086),
198
- model: Some("gpt-3.5-turbo".to_string()),
199
- error_message: Some("API rate limit exceeded".to_string()),
200
- logs: vec![
201
- "[00:00] Run started".to_string(),
202
- "[00:30] Loading template...".to_string(),
203
- "[01:05] ERROR: API rate limit exceeded".to_string(),
204
- ],
205
- },
206
- ],
207
- selected_index: 0,
208
- filter: RunFilter::All,
209
- sort: RunSort::DateDesc,
210
- loading: false,
211
- last_refresh: None,
212
- detail_view: None,
213
- }
214
- }
215
- }
216
-
217
- impl RunManagerState {
218
- /// Get filtered and sorted runs
219
- pub fn filtered_runs(&self) -> Vec<&RunInfo> {
220
- let mut runs: Vec<&RunInfo> = self.runs.iter()
221
- .filter(|r| self.filter.matches(&r.status))
222
- .collect();
223
-
224
- // Sort
225
- match self.sort {
226
- RunSort::DateDesc => {
227
- // Already in desc order (newest first)
228
- }
229
- RunSort::DateAsc => {
230
- runs.reverse();
231
- }
232
- RunSort::Status => {
233
- runs.sort_by_key(|r| r.status.as_str());
234
- }
235
- RunSort::Agent => {
236
- runs.sort_by_key(|r| &r.agent_name);
237
- }
238
- }
239
-
240
- runs
241
- }
242
-
243
- /// Get currently selected run
244
- pub fn selected_run(&self) -> Option<&RunInfo> {
245
- let filtered = self.filtered_runs();
246
- filtered.get(self.selected_index).copied()
247
- }
248
-
249
- /// Toggle detail view for selected run
250
- pub fn toggle_detail_view(&mut self) {
251
- if self.detail_view.is_some() {
252
- // Close detail view
253
- self.detail_view = None;
254
- } else {
255
- // Open detail view for selected run
256
- self.detail_view = Some(self.selected_index);
257
- }
258
- }
259
-
260
- /// Close detail view
261
- pub fn close_detail_view(&mut self) {
262
- self.detail_view = None;
263
- }
264
-
265
- /// Check if in detail view
266
- pub fn is_detail_view(&self) -> bool {
267
- self.detail_view.is_some()
268
- }
269
-
270
- /// Move selection up
271
- pub fn select_previous(&mut self) {
272
- if self.selected_index > 0 {
273
- self.selected_index -= 1;
274
- }
275
- }
276
-
277
- /// Move selection down
278
- pub fn select_next(&mut self) {
279
- let filtered_count = self.filtered_runs().len();
280
- if self.selected_index + 1 < filtered_count {
281
- self.selected_index += 1;
282
- }
283
- }
284
-
285
- /// Cycle to next filter
286
- pub fn next_filter(&mut self) {
287
- self.filter = match self.filter {
288
- RunFilter::All => RunFilter::Active,
289
- RunFilter::Active => RunFilter::Completed,
290
- RunFilter::Completed => RunFilter::Failed,
291
- RunFilter::Failed => RunFilter::All,
292
- };
293
- self.selected_index = 0; // Reset selection
294
- }
295
-
296
- /// Cycle to next sort
297
- pub fn next_sort(&mut self) {
298
- self.sort = match self.sort {
299
- RunSort::DateDesc => RunSort::DateAsc,
300
- RunSort::DateAsc => RunSort::Status,
301
- RunSort::Status => RunSort::Agent,
302
- RunSort::Agent => RunSort::DateDesc,
303
- };
304
- }
305
- }
306
-
307
- /// Render the Run Manager screen
308
- pub fn render(f: &mut Frame, state: &AppState) {
309
- let area = f.size();
310
-
311
- // Get run manager state from AppState
312
- let run_state = &state.run_manager;
313
-
314
- // Check if in detail view
315
- if run_state.is_detail_view() {
316
- render_detail_view(f, area, run_state);
317
- } else {
318
- // Split screen into sections
319
- use ratatui::layout::{Constraint, Direction, Layout};
320
-
321
- let chunks = Layout::default()
322
- .direction(Direction::Vertical)
323
- .constraints([
324
- Constraint::Length(3), // Header
325
- Constraint::Min(10), // Run list
326
- Constraint::Length(8), // Details panel
327
- Constraint::Length(3), // Actions
328
- ])
329
- .split(area);
330
-
331
- render_header(f, chunks[0], run_state);
332
- render_run_list(f, chunks[1], run_state);
333
- render_details_panel(f, chunks[2], run_state);
334
- render_actions(f, chunks[3]);
335
- }
336
- }
337
-
338
- fn render_header(f: &mut Frame, area: Rect, state: &RunManagerState) {
339
- let filtered_count = state.filtered_runs().len();
340
- let total_count = state.runs.len();
341
-
342
- let block = Block::default()
343
- .title(format!(
344
- " ⚡ Run Manager - {} ({}/{} runs) ",
345
- state.filter.as_str(),
346
- filtered_count,
347
- total_count
348
- ))
349
- .borders(Borders::ALL)
350
- .border_style(Style::default().fg(BRAND_PURPLE))
351
- .style(Style::default().bg(BG_PANEL));
352
-
353
- f.render_widget(block, area);
354
- }
355
-
356
- fn render_run_list(f: &mut Frame, area: Rect, state: &RunManagerState) {
357
- let block = Block::default()
358
- .title(format!(" 📋 Runs (Sort: {}) ", state.sort.as_str()))
359
- .borders(Borders::ALL)
360
- .border_style(Style::default().fg(CYBER_CYAN))
361
- .style(Style::default().bg(BG_PANEL));
362
-
363
- let inner = block.inner(area);
364
- f.render_widget(block, area);
365
-
366
- let filtered_runs = state.filtered_runs();
367
-
368
- if filtered_runs.is_empty() {
369
- let text = Paragraph::new("No runs found")
370
- .style(Style::default().fg(Color::DarkGray))
371
- .alignment(Alignment::Center);
372
- f.render_widget(text, inner);
373
- return;
374
- }
375
-
376
- let items: Vec<ListItem> = filtered_runs.iter().enumerate().map(|(i, run)| {
377
- let is_selected = i == state.selected_index;
378
-
379
- let progress_bar = if let Some(progress) = run.progress {
380
- let filled = (progress * 20.0) as usize;
381
- let empty = 20 - filled;
382
- format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
383
- } else {
384
- "[░░░░░░░░░░░░░░░░░░░░]".to_string()
385
- };
386
-
387
- let duration_str = run.duration.as_deref().unwrap_or("--");
388
-
389
- // Build line with colored spans
390
- let line = Line::from(vec![
391
- Span::styled(
392
- if is_selected { "▶ " } else { " " },
393
- Style::default().fg(BRAND_PURPLE).bold()
394
- ),
395
- Span::styled(
396
- format!("{:<10}", run.status.as_str()),
397
- Style::default().fg(run.status.color()).bold()
398
- ),
399
- Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
400
- Span::styled(
401
- format!("{:<20}", run.name),
402
- Style::default().fg(if is_selected { CYBER_CYAN } else { TEXT_PRIMARY }).bold()
403
- ),
404
- Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
405
- Span::styled(
406
- format!("{:<15}", run.agent_name),
407
- Style::default().fg(TEXT_DIM)
408
- ),
409
- Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
410
- Span::styled(
411
- format!("{:>8}", duration_str),
412
- Style::default().fg(TEXT_MUTED)
413
- ),
414
- Span::raw(" "),
415
- Span::styled(
416
- progress_bar,
417
- Style::default().fg(if run.progress.unwrap_or(0.0) > 0.5 { NEON_GREEN } else { AMBER_WARN })
418
- ),
419
- ]);
420
-
421
- ListItem::new(line)
422
- }).collect();
423
-
424
- let list = List::new(items);
425
- f.render_widget(list, inner);
426
- }
427
-
428
- fn render_details_panel(f: &mut Frame, area: Rect, state: &RunManagerState) {
429
- let block = Block::default()
430
- .title(" 🔍 Run Details ")
431
- .borders(Borders::ALL)
432
- .border_style(Style::default().fg(NEON_GREEN))
433
- .style(Style::default().bg(BG_PANEL));
434
-
435
- let inner = block.inner(area);
436
- f.render_widget(block, area);
437
-
438
- if let Some(run) = state.selected_run() {
439
- let text = vec![
440
- Line::from(vec![
441
- Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
442
- Span::styled(&run.id, Style::default().fg(AMBER_WARN)),
443
- ]),
444
- Line::from(vec![
445
- Span::styled("Name: ", Style::default().fg(TEXT_DIM)),
446
- Span::styled(&run.name, Style::default().fg(TEXT_PRIMARY).bold()),
447
- ]),
448
- Line::from(vec![
449
- Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
450
- Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN)),
451
- ]),
452
- Line::from(vec![
453
- Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
454
- Span::styled(run.status.as_str(), Style::default().fg(run.status.color()).bold()),
455
- ]),
456
- Line::from(vec![
457
- Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
458
- Span::styled(&run.started_at, Style::default().fg(TEXT_MUTED)),
459
- ]),
460
- ];
461
-
462
- let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
463
- f.render_widget(paragraph, inner);
464
- } else {
465
- let text = Paragraph::new("No run selected")
466
- .style(Style::default().fg(TEXT_DIM))
467
- .alignment(Alignment::Center);
468
- f.render_widget(text, inner);
469
- }
470
- }
471
-
472
- fn render_detail_view(f: &mut Frame, area: Rect, state: &RunManagerState) {
473
- use ratatui::layout::{Constraint, Direction, Layout};
474
-
475
- // Split into header, content, and footer
476
- let chunks = Layout::default()
477
- .direction(Direction::Vertical)
478
- .constraints([
479
- Constraint::Length(3), // Header
480
- Constraint::Min(10), // Content
481
- Constraint::Length(3), // Footer
482
- ])
483
- .split(area);
484
-
485
- // Get the selected run
486
- let run = match state.selected_run() {
487
- Some(r) => r,
488
- None => {
489
- let block = Block::default()
490
- .title(" Run Details ")
491
- .borders(Borders::ALL)
492
- .border_style(Style::default().fg(TEXT_DIM));
493
- f.render_widget(block, area);
494
- return;
495
- }
496
- };
497
-
498
- // Header
499
- let header_block = Block::default()
500
- .title(format!(" 📊 Run Details - {} ", run.name))
501
- .borders(Borders::ALL)
502
- .border_style(Style::default().fg(BRAND_PURPLE))
503
- .style(Style::default().bg(BG_PANEL));
504
- f.render_widget(header_block, chunks[0]);
505
-
506
- // Content - split into left (info) and right (logs)
507
- let content_chunks = Layout::default()
508
- .direction(Direction::Horizontal)
509
- .constraints([
510
- Constraint::Percentage(40), // Info
511
- Constraint::Percentage(60), // Logs
512
- ])
513
- .split(chunks[1]);
514
-
515
- // Left: Run Info
516
- let info_block = Block::default()
517
- .title(" ℹ️ Information ")
518
- .borders(Borders::ALL)
519
- .border_style(Style::default().fg(CYBER_CYAN))
520
- .style(Style::default().bg(BG_PANEL));
521
-
522
- let info_inner = info_block.inner(content_chunks[0]);
523
- f.render_widget(info_block, content_chunks[0]);
524
-
525
- let mut info_lines = vec![
526
- Line::from(""),
527
- Line::from(vec![
528
- Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
529
- Span::styled(&run.id, Style::default().fg(TEXT_PRIMARY)),
530
- ]),
531
- Line::from(vec![
532
- Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
533
- Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN).bold()),
534
- ]),
535
- Line::from(vec![
536
- Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
537
- Span::styled(run.status.as_str(), Style::default().fg(run.status.color()).bold()),
538
- ]),
539
- Line::from(""),
540
- Line::from(vec![
541
- Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
542
- Span::styled(&run.started_at, Style::default().fg(TEXT_PRIMARY)),
543
- ]),
544
- Line::from(vec![
545
- Span::styled("Duration: ", Style::default().fg(TEXT_DIM)),
546
- Span::styled(run.duration.as_deref().unwrap_or("--"), Style::default().fg(TEXT_PRIMARY)),
547
- ]),
548
- Line::from(""),
549
- ];
550
-
551
- // Add model info if available
552
- if let Some(model) = &run.model {
553
- info_lines.push(Line::from(vec![
554
- Span::styled("Model: ", Style::default().fg(TEXT_DIM)),
555
- Span::styled(model, Style::default().fg(TEXT_PRIMARY)),
556
- ]));
557
- }
558
-
559
- // Add token usage if available
560
- if let (Some(input), Some(output)) = (run.input_tokens, run.output_tokens) {
561
- info_lines.push(Line::from(""));
562
- info_lines.push(Line::from("Token Usage:").style(Style::default().fg(NEON_GREEN).bold()));
563
- info_lines.push(Line::from(vec![
564
- Span::styled(" Input: ", Style::default().fg(TEXT_DIM)),
565
- Span::styled(format!("{}", input), Style::default().fg(TEXT_PRIMARY)),
566
- ]));
567
- info_lines.push(Line::from(vec![
568
- Span::styled(" Output: ", Style::default().fg(TEXT_DIM)),
569
- Span::styled(format!("{}", output), Style::default().fg(TEXT_PRIMARY)),
570
- ]));
571
- info_lines.push(Line::from(vec![
572
- Span::styled(" Total: ", Style::default().fg(TEXT_DIM)),
573
- Span::styled(format!("{}", input + output), Style::default().fg(NEON_GREEN).bold()),
574
- ]));
575
- }
576
-
577
- // Add cost if available
578
- if let Some(cost) = run.total_cost {
579
- info_lines.push(Line::from(""));
580
- info_lines.push(Line::from(vec![
581
- Span::styled("Cost: ", Style::default().fg(TEXT_DIM)),
582
- Span::styled(format!("${:.4}", cost), Style::default().fg(AMBER_WARN).bold()),
583
- ]));
584
- }
585
-
586
- // Add error if present
587
- if let Some(error) = &run.error_message {
588
- info_lines.push(Line::from(""));
589
- info_lines.push(Line::from("Error:").style(Style::default().fg(Color::Rgb(255, 69, 69)).bold()));
590
- info_lines.push(Line::from(format!(" {}", error)).style(Style::default().fg(Color::Rgb(255, 69, 69))));
591
- }
592
-
593
- let info_paragraph = Paragraph::new(info_lines)
594
- .wrap(Wrap { trim: false });
595
- f.render_widget(info_paragraph, info_inner);
596
-
597
- // Right: Logs
598
- let logs_block = Block::default()
599
- .title(" 📜 Execution Logs ")
600
- .borders(Borders::ALL)
601
- .border_style(Style::default().fg(NEON_GREEN))
602
- .style(Style::default().bg(BG_PANEL));
603
-
604
- let logs_inner = logs_block.inner(content_chunks[1]);
605
- f.render_widget(logs_block, content_chunks[1]);
606
-
607
- let log_lines: Vec<Line> = if run.logs.is_empty() {
608
- vec![
609
- Line::from(""),
610
- Line::from("No logs available").style(Style::default().fg(TEXT_DIM)),
611
- ]
612
- } else {
613
- run.logs.iter().map(|log| {
614
- Line::from(log.as_str()).style(Style::default().fg(TEXT_PRIMARY))
615
- }).collect()
616
- };
617
-
618
- let logs_paragraph = Paragraph::new(log_lines)
619
- .wrap(Wrap { trim: false });
620
- f.render_widget(logs_paragraph, logs_inner);
621
-
622
- // Footer
623
- let footer_block = Block::default()
624
- .title(" ⌨️ Actions ")
625
- .borders(Borders::ALL)
626
- .border_style(Style::default().fg(TEXT_DIM))
627
- .style(Style::default().bg(BG_PANEL));
628
-
629
- let footer_inner = footer_block.inner(chunks[2]);
630
- f.render_widget(footer_block, chunks[2]);
631
-
632
- let footer_text = Line::from(vec![
633
- Span::styled("ESC/Enter", Style::default().fg(BRAND_PURPLE).bold()),
634
- Span::styled(" Back to List │ ", Style::default().fg(TEXT_DIM)),
635
- Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
636
- Span::styled(" Refresh", Style::default().fg(TEXT_DIM)),
637
- ]);
638
-
639
- let footer_paragraph = Paragraph::new(footer_text)
640
- .alignment(Alignment::Center);
641
- f.render_widget(footer_paragraph, footer_inner);
642
- }
643
-
644
- fn render_actions(f: &mut Frame, area: Rect) {
645
- let block = Block::default()
646
- .title(" ⌨️ Actions ")
647
- .borders(Borders::ALL)
648
- .border_style(Style::default().fg(TEXT_DIM))
649
- .style(Style::default().bg(BG_PANEL));
650
-
651
- let inner = block.inner(area);
652
- f.render_widget(block, area);
653
-
654
- let text = Line::from(vec![
655
- Span::styled("↑/↓", Style::default().fg(CYBER_CYAN).bold()),
656
- Span::styled(" Select │ ", Style::default().fg(TEXT_DIM)),
657
- Span::styled("F", Style::default().fg(AMBER_WARN).bold()),
658
- Span::styled(" Filter │ ", Style::default().fg(TEXT_DIM)),
659
- Span::styled("S", Style::default().fg(AMBER_WARN).bold()),
660
- Span::styled(" Sort │ ", Style::default().fg(TEXT_DIM)),
661
- Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
662
- Span::styled(" Refresh │ ", Style::default().fg(TEXT_DIM)),
663
- Span::styled("C", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
664
- Span::styled(" Cancel │ ", Style::default().fg(TEXT_DIM)),
665
- Span::styled("D", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
666
- Span::styled(" Delete │ ", Style::default().fg(TEXT_DIM)),
667
- Span::styled("ESC", Style::default().fg(BRAND_PURPLE).bold()),
668
- Span::styled(" Close", Style::default().fg(TEXT_DIM)),
669
- ]);
670
-
671
- let paragraph = Paragraph::new(text)
672
- .style(Style::default().fg(TEXT_PRIMARY))
673
- .alignment(Alignment::Center);
674
-
675
- f.render_widget(paragraph, inner);
676
- }
1
+ /// Run Manager Screen
2
+ /// View, filter, and manage agent runs
3
+
4
+ use ratatui::prelude::*;
5
+ use ratatui::widgets::{Block, Borders, Paragraph, Wrap, List, ListItem};
6
+ use crate::app::AppState;
7
+
8
+ // === 4RUNR BRAND COLORS (matching layout.rs) ===
9
+ const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
+ const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
11
+ const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
12
+ const AMBER_WARN: Color = Color::Rgb(255, 191, 0);
13
+ const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
14
+ const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
15
+ const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
16
+ const BG_PANEL: Color = Color::Rgb(18, 18, 25);
17
+
18
+ /// Run Manager state
19
+ #[derive(Debug, Clone)]
20
+ pub struct RunManagerState {
21
+ /// List of runs
22
+ pub runs: Vec<RunInfo>,
23
+
24
+ /// Selected run index
25
+ pub selected_index: usize,
26
+
27
+ /// Filter state
28
+ pub filter: RunFilter,
29
+
30
+ /// Sort state
31
+ pub sort: RunSort,
32
+
33
+ /// Loading state
34
+ pub loading: bool,
35
+
36
+ /// Last refresh time
37
+ pub last_refresh: Option<std::time::Instant>,
38
+
39
+ /// Detailed view state (None = list view, Some(index) = detail view)
40
+ pub detail_view: Option<usize>,
41
+ }
42
+
43
+ #[derive(Debug, Clone)]
44
+ pub struct RunInfo {
45
+ pub id: String,
46
+ pub name: String,
47
+ pub agent_name: String,
48
+ pub status: RunStatus,
49
+ pub started_at: String,
50
+ pub duration: Option<String>,
51
+ pub progress: Option<f32>, // 0.0-1.0
52
+
53
+ // Extended details
54
+ pub input_tokens: Option<u32>,
55
+ pub output_tokens: Option<u32>,
56
+ pub total_cost: Option<f64>,
57
+ pub model: Option<String>,
58
+ pub error_message: Option<String>,
59
+ pub logs: Vec<String>,
60
+ }
61
+
62
+ #[derive(Debug, Clone, PartialEq)]
63
+ pub enum RunStatus {
64
+ Pending,
65
+ Running,
66
+ Completed,
67
+ Failed,
68
+ Cancelled,
69
+ }
70
+
71
+ impl RunStatus {
72
+ pub fn as_str(&self) -> &str {
73
+ match self {
74
+ RunStatus::Pending => "PENDING",
75
+ RunStatus::Running => "RUNNING",
76
+ RunStatus::Completed => "COMPLETED",
77
+ RunStatus::Failed => "FAILED",
78
+ RunStatus::Cancelled => "CANCELLED",
79
+ }
80
+ }
81
+
82
+ pub fn color(&self) -> Color {
83
+ match self {
84
+ RunStatus::Pending => AMBER_WARN,
85
+ RunStatus::Running => CYBER_CYAN,
86
+ RunStatus::Completed => NEON_GREEN,
87
+ RunStatus::Failed => Color::Rgb(255, 69, 69), // Bright red
88
+ RunStatus::Cancelled => TEXT_DIM,
89
+ }
90
+ }
91
+ }
92
+
93
+ #[derive(Debug, Clone, PartialEq)]
94
+ pub enum RunFilter {
95
+ All,
96
+ Active, // Pending + Running
97
+ Completed,
98
+ Failed,
99
+ }
100
+
101
+ impl RunFilter {
102
+ pub fn as_str(&self) -> &str {
103
+ match self {
104
+ RunFilter::All => "All",
105
+ RunFilter::Active => "Active",
106
+ RunFilter::Completed => "Completed",
107
+ RunFilter::Failed => "Failed",
108
+ }
109
+ }
110
+
111
+ pub fn matches(&self, status: &RunStatus) -> bool {
112
+ match self {
113
+ RunFilter::All => true,
114
+ RunFilter::Active => matches!(status, RunStatus::Pending | RunStatus::Running),
115
+ RunFilter::Completed => matches!(status, RunStatus::Completed),
116
+ RunFilter::Failed => matches!(status, RunStatus::Failed),
117
+ }
118
+ }
119
+ }
120
+
121
+ #[derive(Debug, Clone, PartialEq)]
122
+ pub enum RunSort {
123
+ DateDesc, // Newest first
124
+ DateAsc, // Oldest first
125
+ Status,
126
+ Agent,
127
+ }
128
+
129
+ impl RunSort {
130
+ pub fn as_str(&self) -> &str {
131
+ match self {
132
+ RunSort::DateDesc => "Date ↓",
133
+ RunSort::DateAsc => "Date ↑",
134
+ RunSort::Status => "Status",
135
+ RunSort::Agent => "Agent",
136
+ }
137
+ }
138
+ }
139
+
140
+ impl Default for RunManagerState {
141
+ fn default() -> Self {
142
+ Self {
143
+ runs: vec![
144
+ // Mock data for testing
145
+ RunInfo {
146
+ id: "run-001".to_string(),
147
+ name: "Data Analysis Task".to_string(),
148
+ agent_name: "data-analyst".to_string(),
149
+ status: RunStatus::Running,
150
+ started_at: "2m ago".to_string(),
151
+ duration: Some("2m 15s".to_string()),
152
+ progress: Some(0.65),
153
+ input_tokens: Some(1250),
154
+ output_tokens: Some(850),
155
+ total_cost: Some(0.0315),
156
+ model: Some("gpt-4-turbo".to_string()),
157
+ error_message: None,
158
+ logs: vec![
159
+ "[00:00] Run started".to_string(),
160
+ "[00:15] Loading data from source...".to_string(),
161
+ "[00:45] Processing 1,234 records...".to_string(),
162
+ "[01:30] Analyzing patterns...".to_string(),
163
+ "[02:15] Generating insights...".to_string(),
164
+ ],
165
+ },
166
+ RunInfo {
167
+ id: "run-002".to_string(),
168
+ name: "Code Review".to_string(),
169
+ agent_name: "code-reviewer".to_string(),
170
+ status: RunStatus::Completed,
171
+ started_at: "15m ago".to_string(),
172
+ duration: Some("3m 42s".to_string()),
173
+ progress: Some(1.0),
174
+ input_tokens: Some(3200),
175
+ output_tokens: Some(1800),
176
+ total_cost: Some(0.075),
177
+ model: Some("gpt-4".to_string()),
178
+ error_message: None,
179
+ logs: vec![
180
+ "[00:00] Run started".to_string(),
181
+ "[00:30] Analyzing code structure...".to_string(),
182
+ "[01:45] Checking for issues...".to_string(),
183
+ "[02:30] Generating recommendations...".to_string(),
184
+ "[03:42] Review complete".to_string(),
185
+ ],
186
+ },
187
+ RunInfo {
188
+ id: "run-003".to_string(),
189
+ name: "Document Generation".to_string(),
190
+ agent_name: "writer".to_string(),
191
+ status: RunStatus::Failed,
192
+ started_at: "1h ago".to_string(),
193
+ duration: Some("1m 05s".to_string()),
194
+ progress: Some(0.25),
195
+ input_tokens: Some(450),
196
+ output_tokens: Some(120),
197
+ total_cost: Some(0.0086),
198
+ model: Some("gpt-3.5-turbo".to_string()),
199
+ error_message: Some("API rate limit exceeded".to_string()),
200
+ logs: vec![
201
+ "[00:00] Run started".to_string(),
202
+ "[00:30] Loading template...".to_string(),
203
+ "[01:05] ERROR: API rate limit exceeded".to_string(),
204
+ ],
205
+ },
206
+ ],
207
+ selected_index: 0,
208
+ filter: RunFilter::All,
209
+ sort: RunSort::DateDesc,
210
+ loading: false,
211
+ last_refresh: None,
212
+ detail_view: None,
213
+ }
214
+ }
215
+ }
216
+
217
+ impl RunManagerState {
218
+ /// Get filtered and sorted runs
219
+ pub fn filtered_runs(&self) -> Vec<&RunInfo> {
220
+ let mut runs: Vec<&RunInfo> = self.runs.iter()
221
+ .filter(|r| self.filter.matches(&r.status))
222
+ .collect();
223
+
224
+ // Sort
225
+ match self.sort {
226
+ RunSort::DateDesc => {
227
+ // Already in desc order (newest first)
228
+ }
229
+ RunSort::DateAsc => {
230
+ runs.reverse();
231
+ }
232
+ RunSort::Status => {
233
+ runs.sort_by_key(|r| r.status.as_str());
234
+ }
235
+ RunSort::Agent => {
236
+ runs.sort_by_key(|r| &r.agent_name);
237
+ }
238
+ }
239
+
240
+ runs
241
+ }
242
+
243
+ /// Get currently selected run
244
+ pub fn selected_run(&self) -> Option<&RunInfo> {
245
+ let filtered = self.filtered_runs();
246
+ filtered.get(self.selected_index).copied()
247
+ }
248
+
249
+ /// Toggle detail view for selected run
250
+ pub fn toggle_detail_view(&mut self) {
251
+ if self.detail_view.is_some() {
252
+ // Close detail view
253
+ self.detail_view = None;
254
+ } else {
255
+ // Open detail view for selected run
256
+ self.detail_view = Some(self.selected_index);
257
+ }
258
+ }
259
+
260
+ /// Close detail view
261
+ pub fn close_detail_view(&mut self) {
262
+ self.detail_view = None;
263
+ }
264
+
265
+ /// Check if in detail view
266
+ pub fn is_detail_view(&self) -> bool {
267
+ self.detail_view.is_some()
268
+ }
269
+
270
+ /// Move selection up
271
+ pub fn select_previous(&mut self) {
272
+ if self.selected_index > 0 {
273
+ self.selected_index -= 1;
274
+ }
275
+ }
276
+
277
+ /// Move selection down
278
+ pub fn select_next(&mut self) {
279
+ let filtered_count = self.filtered_runs().len();
280
+ if self.selected_index + 1 < filtered_count {
281
+ self.selected_index += 1;
282
+ }
283
+ }
284
+
285
+ /// Cycle to next filter
286
+ pub fn next_filter(&mut self) {
287
+ self.filter = match self.filter {
288
+ RunFilter::All => RunFilter::Active,
289
+ RunFilter::Active => RunFilter::Completed,
290
+ RunFilter::Completed => RunFilter::Failed,
291
+ RunFilter::Failed => RunFilter::All,
292
+ };
293
+ self.selected_index = 0; // Reset selection
294
+ }
295
+
296
+ /// Cycle to next sort
297
+ pub fn next_sort(&mut self) {
298
+ self.sort = match self.sort {
299
+ RunSort::DateDesc => RunSort::DateAsc,
300
+ RunSort::DateAsc => RunSort::Status,
301
+ RunSort::Status => RunSort::Agent,
302
+ RunSort::Agent => RunSort::DateDesc,
303
+ };
304
+ }
305
+ }
306
+
307
+ /// Render the Run Manager screen
308
+ pub fn render(f: &mut Frame, state: &AppState) {
309
+ let area = f.size();
310
+
311
+ // Get run manager state from AppState
312
+ let run_state = &state.run_manager;
313
+
314
+ // Check if in detail view
315
+ if run_state.is_detail_view() {
316
+ render_detail_view(f, area, run_state);
317
+ } else {
318
+ // Split screen into sections
319
+ use ratatui::layout::{Constraint, Direction, Layout};
320
+
321
+ let chunks = Layout::default()
322
+ .direction(Direction::Vertical)
323
+ .constraints([
324
+ Constraint::Length(3), // Header
325
+ Constraint::Min(10), // Run list
326
+ Constraint::Length(8), // Details panel
327
+ Constraint::Length(3), // Actions
328
+ ])
329
+ .split(area);
330
+
331
+ render_header(f, chunks[0], run_state);
332
+ render_run_list(f, chunks[1], run_state);
333
+ render_details_panel(f, chunks[2], run_state);
334
+ render_actions(f, chunks[3]);
335
+ }
336
+ }
337
+
338
+ fn render_header(f: &mut Frame, area: Rect, state: &RunManagerState) {
339
+ let filtered_count = state.filtered_runs().len();
340
+ let total_count = state.runs.len();
341
+
342
+ let block = Block::default()
343
+ .title(format!(
344
+ " ⚡ Run Manager - {} ({}/{} runs) ",
345
+ state.filter.as_str(),
346
+ filtered_count,
347
+ total_count
348
+ ))
349
+ .borders(Borders::ALL)
350
+ .border_style(Style::default().fg(BRAND_PURPLE))
351
+ .style(Style::default().bg(BG_PANEL));
352
+
353
+ f.render_widget(block, area);
354
+ }
355
+
356
+ fn render_run_list(f: &mut Frame, area: Rect, state: &RunManagerState) {
357
+ let block = Block::default()
358
+ .title(format!(" 📋 Runs (Sort: {}) ", state.sort.as_str()))
359
+ .borders(Borders::ALL)
360
+ .border_style(Style::default().fg(CYBER_CYAN))
361
+ .style(Style::default().bg(BG_PANEL));
362
+
363
+ let inner = block.inner(area);
364
+ f.render_widget(block, area);
365
+
366
+ let filtered_runs = state.filtered_runs();
367
+
368
+ if filtered_runs.is_empty() {
369
+ let text = Paragraph::new("No runs found")
370
+ .style(Style::default().fg(Color::DarkGray))
371
+ .alignment(Alignment::Center);
372
+ f.render_widget(text, inner);
373
+ return;
374
+ }
375
+
376
+ let items: Vec<ListItem> = filtered_runs.iter().enumerate().map(|(i, run)| {
377
+ let is_selected = i == state.selected_index;
378
+
379
+ let progress_bar = if let Some(progress) = run.progress {
380
+ let filled = (progress * 20.0) as usize;
381
+ let empty = 20 - filled;
382
+ format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
383
+ } else {
384
+ "[░░░░░░░░░░░░░░░░░░░░]".to_string()
385
+ };
386
+
387
+ let duration_str = run.duration.as_deref().unwrap_or("--");
388
+
389
+ // Build line with colored spans
390
+ let line = Line::from(vec![
391
+ Span::styled(
392
+ if is_selected { "▶ " } else { " " },
393
+ Style::default().fg(BRAND_PURPLE).bold()
394
+ ),
395
+ Span::styled(
396
+ format!("{:<10}", run.status.as_str()),
397
+ Style::default().fg(run.status.color()).bold()
398
+ ),
399
+ Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
400
+ Span::styled(
401
+ format!("{:<20}", run.name),
402
+ Style::default().fg(if is_selected { CYBER_CYAN } else { TEXT_PRIMARY }).bold()
403
+ ),
404
+ Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
405
+ Span::styled(
406
+ format!("{:<15}", run.agent_name),
407
+ Style::default().fg(TEXT_DIM)
408
+ ),
409
+ Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
410
+ Span::styled(
411
+ format!("{:>8}", duration_str),
412
+ Style::default().fg(TEXT_MUTED)
413
+ ),
414
+ Span::raw(" "),
415
+ Span::styled(
416
+ progress_bar,
417
+ Style::default().fg(if run.progress.unwrap_or(0.0) > 0.5 { NEON_GREEN } else { AMBER_WARN })
418
+ ),
419
+ ]);
420
+
421
+ ListItem::new(line)
422
+ }).collect();
423
+
424
+ let list = List::new(items);
425
+ f.render_widget(list, inner);
426
+ }
427
+
428
+ fn render_details_panel(f: &mut Frame, area: Rect, state: &RunManagerState) {
429
+ let block = Block::default()
430
+ .title(" 🔍 Run Details ")
431
+ .borders(Borders::ALL)
432
+ .border_style(Style::default().fg(NEON_GREEN))
433
+ .style(Style::default().bg(BG_PANEL));
434
+
435
+ let inner = block.inner(area);
436
+ f.render_widget(block, area);
437
+
438
+ if let Some(run) = state.selected_run() {
439
+ let text = vec![
440
+ Line::from(vec![
441
+ Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
442
+ Span::styled(&run.id, Style::default().fg(AMBER_WARN)),
443
+ ]),
444
+ Line::from(vec![
445
+ Span::styled("Name: ", Style::default().fg(TEXT_DIM)),
446
+ Span::styled(&run.name, Style::default().fg(TEXT_PRIMARY).bold()),
447
+ ]),
448
+ Line::from(vec![
449
+ Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
450
+ Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN)),
451
+ ]),
452
+ Line::from(vec![
453
+ Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
454
+ Span::styled(run.status.as_str(), Style::default().fg(run.status.color()).bold()),
455
+ ]),
456
+ Line::from(vec![
457
+ Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
458
+ Span::styled(&run.started_at, Style::default().fg(TEXT_MUTED)),
459
+ ]),
460
+ ];
461
+
462
+ let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
463
+ f.render_widget(paragraph, inner);
464
+ } else {
465
+ let text = Paragraph::new("No run selected")
466
+ .style(Style::default().fg(TEXT_DIM))
467
+ .alignment(Alignment::Center);
468
+ f.render_widget(text, inner);
469
+ }
470
+ }
471
+
472
+ fn render_detail_view(f: &mut Frame, area: Rect, state: &RunManagerState) {
473
+ use ratatui::layout::{Constraint, Direction, Layout};
474
+
475
+ // Split into header, content, and footer
476
+ let chunks = Layout::default()
477
+ .direction(Direction::Vertical)
478
+ .constraints([
479
+ Constraint::Length(3), // Header
480
+ Constraint::Min(10), // Content
481
+ Constraint::Length(3), // Footer
482
+ ])
483
+ .split(area);
484
+
485
+ // Get the selected run
486
+ let run = match state.selected_run() {
487
+ Some(r) => r,
488
+ None => {
489
+ let block = Block::default()
490
+ .title(" Run Details ")
491
+ .borders(Borders::ALL)
492
+ .border_style(Style::default().fg(TEXT_DIM));
493
+ f.render_widget(block, area);
494
+ return;
495
+ }
496
+ };
497
+
498
+ // Header
499
+ let header_block = Block::default()
500
+ .title(format!(" 📊 Run Details - {} ", run.name))
501
+ .borders(Borders::ALL)
502
+ .border_style(Style::default().fg(BRAND_PURPLE))
503
+ .style(Style::default().bg(BG_PANEL));
504
+ f.render_widget(header_block, chunks[0]);
505
+
506
+ // Content - split into left (info) and right (logs)
507
+ let content_chunks = Layout::default()
508
+ .direction(Direction::Horizontal)
509
+ .constraints([
510
+ Constraint::Percentage(40), // Info
511
+ Constraint::Percentage(60), // Logs
512
+ ])
513
+ .split(chunks[1]);
514
+
515
+ // Left: Run Info
516
+ let info_block = Block::default()
517
+ .title(" ℹ️ Information ")
518
+ .borders(Borders::ALL)
519
+ .border_style(Style::default().fg(CYBER_CYAN))
520
+ .style(Style::default().bg(BG_PANEL));
521
+
522
+ let info_inner = info_block.inner(content_chunks[0]);
523
+ f.render_widget(info_block, content_chunks[0]);
524
+
525
+ let mut info_lines = vec![
526
+ Line::from(""),
527
+ Line::from(vec![
528
+ Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
529
+ Span::styled(&run.id, Style::default().fg(TEXT_PRIMARY)),
530
+ ]),
531
+ Line::from(vec![
532
+ Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
533
+ Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN).bold()),
534
+ ]),
535
+ Line::from(vec![
536
+ Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
537
+ Span::styled(run.status.as_str(), Style::default().fg(run.status.color()).bold()),
538
+ ]),
539
+ Line::from(""),
540
+ Line::from(vec![
541
+ Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
542
+ Span::styled(&run.started_at, Style::default().fg(TEXT_PRIMARY)),
543
+ ]),
544
+ Line::from(vec![
545
+ Span::styled("Duration: ", Style::default().fg(TEXT_DIM)),
546
+ Span::styled(run.duration.as_deref().unwrap_or("--"), Style::default().fg(TEXT_PRIMARY)),
547
+ ]),
548
+ Line::from(""),
549
+ ];
550
+
551
+ // Add model info if available
552
+ if let Some(model) = &run.model {
553
+ info_lines.push(Line::from(vec![
554
+ Span::styled("Model: ", Style::default().fg(TEXT_DIM)),
555
+ Span::styled(model, Style::default().fg(TEXT_PRIMARY)),
556
+ ]));
557
+ }
558
+
559
+ // Add token usage if available
560
+ if let (Some(input), Some(output)) = (run.input_tokens, run.output_tokens) {
561
+ info_lines.push(Line::from(""));
562
+ info_lines.push(Line::from("Token Usage:").style(Style::default().fg(NEON_GREEN).bold()));
563
+ info_lines.push(Line::from(vec![
564
+ Span::styled(" Input: ", Style::default().fg(TEXT_DIM)),
565
+ Span::styled(format!("{}", input), Style::default().fg(TEXT_PRIMARY)),
566
+ ]));
567
+ info_lines.push(Line::from(vec![
568
+ Span::styled(" Output: ", Style::default().fg(TEXT_DIM)),
569
+ Span::styled(format!("{}", output), Style::default().fg(TEXT_PRIMARY)),
570
+ ]));
571
+ info_lines.push(Line::from(vec![
572
+ Span::styled(" Total: ", Style::default().fg(TEXT_DIM)),
573
+ Span::styled(format!("{}", input + output), Style::default().fg(NEON_GREEN).bold()),
574
+ ]));
575
+ }
576
+
577
+ // Add cost if available
578
+ if let Some(cost) = run.total_cost {
579
+ info_lines.push(Line::from(""));
580
+ info_lines.push(Line::from(vec![
581
+ Span::styled("Cost: ", Style::default().fg(TEXT_DIM)),
582
+ Span::styled(format!("${:.4}", cost), Style::default().fg(AMBER_WARN).bold()),
583
+ ]));
584
+ }
585
+
586
+ // Add error if present
587
+ if let Some(error) = &run.error_message {
588
+ info_lines.push(Line::from(""));
589
+ info_lines.push(Line::from("Error:").style(Style::default().fg(Color::Rgb(255, 69, 69)).bold()));
590
+ info_lines.push(Line::from(format!(" {}", error)).style(Style::default().fg(Color::Rgb(255, 69, 69))));
591
+ }
592
+
593
+ let info_paragraph = Paragraph::new(info_lines)
594
+ .wrap(Wrap { trim: false });
595
+ f.render_widget(info_paragraph, info_inner);
596
+
597
+ // Right: Logs
598
+ let logs_block = Block::default()
599
+ .title(" 📜 Execution Logs ")
600
+ .borders(Borders::ALL)
601
+ .border_style(Style::default().fg(NEON_GREEN))
602
+ .style(Style::default().bg(BG_PANEL));
603
+
604
+ let logs_inner = logs_block.inner(content_chunks[1]);
605
+ f.render_widget(logs_block, content_chunks[1]);
606
+
607
+ let log_lines: Vec<Line> = if run.logs.is_empty() {
608
+ vec![
609
+ Line::from(""),
610
+ Line::from("No logs available").style(Style::default().fg(TEXT_DIM)),
611
+ ]
612
+ } else {
613
+ run.logs.iter().map(|log| {
614
+ Line::from(log.as_str()).style(Style::default().fg(TEXT_PRIMARY))
615
+ }).collect()
616
+ };
617
+
618
+ let logs_paragraph = Paragraph::new(log_lines)
619
+ .wrap(Wrap { trim: false });
620
+ f.render_widget(logs_paragraph, logs_inner);
621
+
622
+ // Footer
623
+ let footer_block = Block::default()
624
+ .title(" ⌨️ Actions ")
625
+ .borders(Borders::ALL)
626
+ .border_style(Style::default().fg(TEXT_DIM))
627
+ .style(Style::default().bg(BG_PANEL));
628
+
629
+ let footer_inner = footer_block.inner(chunks[2]);
630
+ f.render_widget(footer_block, chunks[2]);
631
+
632
+ let footer_text = Line::from(vec![
633
+ Span::styled("ESC/Enter", Style::default().fg(BRAND_PURPLE).bold()),
634
+ Span::styled(" Back to List │ ", Style::default().fg(TEXT_DIM)),
635
+ Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
636
+ Span::styled(" Refresh", Style::default().fg(TEXT_DIM)),
637
+ ]);
638
+
639
+ let footer_paragraph = Paragraph::new(footer_text)
640
+ .alignment(Alignment::Center);
641
+ f.render_widget(footer_paragraph, footer_inner);
642
+ }
643
+
644
+ fn render_actions(f: &mut Frame, area: Rect) {
645
+ let block = Block::default()
646
+ .title(" ⌨️ Actions ")
647
+ .borders(Borders::ALL)
648
+ .border_style(Style::default().fg(TEXT_DIM))
649
+ .style(Style::default().bg(BG_PANEL));
650
+
651
+ let inner = block.inner(area);
652
+ f.render_widget(block, area);
653
+
654
+ let text = Line::from(vec![
655
+ Span::styled("↑/↓", Style::default().fg(CYBER_CYAN).bold()),
656
+ Span::styled(" Select │ ", Style::default().fg(TEXT_DIM)),
657
+ Span::styled("F", Style::default().fg(AMBER_WARN).bold()),
658
+ Span::styled(" Filter │ ", Style::default().fg(TEXT_DIM)),
659
+ Span::styled("S", Style::default().fg(AMBER_WARN).bold()),
660
+ Span::styled(" Sort │ ", Style::default().fg(TEXT_DIM)),
661
+ Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
662
+ Span::styled(" Refresh │ ", Style::default().fg(TEXT_DIM)),
663
+ Span::styled("C", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
664
+ Span::styled(" Cancel │ ", Style::default().fg(TEXT_DIM)),
665
+ Span::styled("D", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
666
+ Span::styled(" Delete │ ", Style::default().fg(TEXT_DIM)),
667
+ Span::styled("ESC", Style::default().fg(BRAND_PURPLE).bold()),
668
+ Span::styled(" Close", Style::default().fg(TEXT_DIM)),
669
+ ]);
670
+
671
+ let paragraph = Paragraph::new(text)
672
+ .style(Style::default().fg(TEXT_PRIMARY))
673
+ .alignment(Alignment::Center);
674
+
675
+ f.render_widget(paragraph, inner);
676
+ }