4runr-os 2.10.39 → 2.10.41

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.
Files changed (51) hide show
  1. package/apps/gateway/dist/apps/gateway/src/index.js +14 -4
  2. package/apps/gateway/dist/apps/gateway/src/index.js.map +1 -1
  3. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts +18 -0
  4. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.d.ts.map +1 -0
  5. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js +117 -0
  6. package/apps/gateway/dist/apps/gateway/src/metrics/monitoring-detail.js.map +1 -0
  7. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts +2 -0
  8. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.d.ts.map +1 -0
  9. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js +54 -0
  10. package/apps/gateway/dist/apps/gateway/src/middleware/log-capture.js.map +1 -0
  11. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts +15 -0
  12. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.d.ts.map +1 -0
  13. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js +164 -0
  14. package/apps/gateway/dist/apps/gateway/src/routes/monitoring.js.map +1 -0
  15. package/apps/gateway/package-lock.json +204 -353
  16. package/apps/gateway/src/index.ts +27 -8
  17. package/apps/gateway/src/metrics/monitoring-detail.ts +162 -0
  18. package/apps/gateway/src/middleware/log-capture.ts +70 -0
  19. package/apps/gateway/src/routes/monitoring.ts +298 -0
  20. package/dist/gateway-client.d.ts +2 -0
  21. package/dist/gateway-client.d.ts.map +1 -1
  22. package/dist/gateway-client.js +22 -0
  23. package/dist/gateway-client.js.map +1 -1
  24. package/dist/tui-handlers.js +498 -0
  25. package/dist/tui-handlers.js.map +1 -1
  26. package/mk3-tui/src/app/render_scheduler.rs +111 -112
  27. package/mk3-tui/src/app.rs +1078 -295
  28. package/mk3-tui/src/debug_log.rs +131 -124
  29. package/mk3-tui/src/io/mod.rs +63 -66
  30. package/mk3-tui/src/io/protocol.rs +14 -15
  31. package/mk3-tui/src/io/stdio.rs +31 -32
  32. package/mk3-tui/src/io/ws.rs +25 -32
  33. package/mk3-tui/src/main.rs +774 -212
  34. package/mk3-tui/src/monitoring/mod.rs +428 -0
  35. package/mk3-tui/src/screens/mod.rs +53 -39
  36. package/mk3-tui/src/storage/cache.rs +221 -224
  37. package/mk3-tui/src/storage/mod.rs +5 -6
  38. package/mk3-tui/src/ui/agent_builder.rs +1148 -922
  39. package/mk3-tui/src/ui/agent_list.rs +344 -295
  40. package/mk3-tui/src/ui/boot.rs +145 -148
  41. package/mk3-tui/src/ui/connection_portal.rs +121 -98
  42. package/mk3-tui/src/ui/help.rs +340 -284
  43. package/mk3-tui/src/ui/layout.rs +966 -803
  44. package/mk3-tui/src/ui/mod.rs +1 -1
  45. package/mk3-tui/src/ui/portal_monitoring.rs +1027 -147
  46. package/mk3-tui/src/ui/run_manager.rs +784 -764
  47. package/mk3-tui/src/ui/safe_viewport.rs +236 -235
  48. package/mk3-tui/src/ui/settings.rs +414 -362
  49. package/mk3-tui/src/ui/setup_portal.rs +158 -101
  50. package/mk3-tui/src/websocket.rs +315 -308
  51. package/package.json +2 -2
@@ -1,764 +1,784 @@
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
- use serde_json::Value;
8
-
9
- // === 4RUNR BRAND COLORS (matching layout.rs) ===
10
- const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
11
- const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
12
- const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
13
- const AMBER_WARN: Color = Color::Rgb(255, 191, 0);
14
- const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
15
- const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
16
- const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
17
- const BG_PANEL: Color = Color::Rgb(18, 18, 25);
18
-
19
- /// Run Manager state
20
- #[derive(Debug, Clone)]
21
- pub struct RunManagerState {
22
- /// List of runs
23
- pub runs: Vec<RunInfo>,
24
-
25
- /// Selected run index
26
- pub selected_index: usize,
27
-
28
- /// Filter state
29
- pub filter: RunFilter,
30
-
31
- /// Sort state
32
- pub sort: RunSort,
33
-
34
- /// Loading state
35
- #[allow(dead_code)]
36
- pub loading: bool,
37
-
38
- /// Last refresh time
39
- #[allow(dead_code)]
40
- pub last_refresh: Option<std::time::Instant>,
41
-
42
- /// Detailed view state (None = list view, Some(index) = detail view)
43
- pub detail_view: Option<usize>,
44
- }
45
-
46
- #[derive(Debug, Clone)]
47
- pub struct RunInfo {
48
- pub id: String,
49
- pub name: String,
50
- pub agent_name: String,
51
- pub status: RunStatus,
52
- /// ISO `createdAt` from gateway (for sorting)
53
- pub created_at_sort: String,
54
- pub started_at: String,
55
- pub duration: Option<String>,
56
- pub progress: Option<f32>, // 0.0-1.0
57
-
58
- // Extended details
59
- pub input_tokens: Option<u32>,
60
- pub output_tokens: Option<u32>,
61
- pub total_cost: Option<f64>,
62
- pub model: Option<String>,
63
- pub error_message: Option<String>,
64
- pub logs: Vec<String>,
65
- }
66
-
67
- #[derive(Debug, Clone, PartialEq)]
68
- pub enum RunStatus {
69
- #[allow(dead_code)]
70
- Pending,
71
- Running,
72
- Completed,
73
- Failed,
74
- #[allow(dead_code)]
75
- Cancelled,
76
- }
77
-
78
- impl RunStatus {
79
- pub fn as_str(&self) -> &str {
80
- match self {
81
- RunStatus::Pending => "PENDING",
82
- RunStatus::Running => "RUNNING",
83
- RunStatus::Completed => "COMPLETED",
84
- RunStatus::Failed => "FAILED",
85
- RunStatus::Cancelled => "CANCELLED",
86
- }
87
- }
88
-
89
- pub fn color(&self) -> Color {
90
- match self {
91
- RunStatus::Pending => AMBER_WARN,
92
- RunStatus::Running => CYBER_CYAN,
93
- RunStatus::Completed => NEON_GREEN,
94
- RunStatus::Failed => Color::Rgb(255, 69, 69), // Bright red
95
- RunStatus::Cancelled => TEXT_DIM,
96
- }
97
- }
98
- }
99
-
100
- #[derive(Debug, Clone, PartialEq)]
101
- pub enum RunFilter {
102
- All,
103
- Active, // Pending + Running
104
- Completed,
105
- Failed,
106
- }
107
-
108
- impl RunFilter {
109
- pub fn as_str(&self) -> &str {
110
- match self {
111
- RunFilter::All => "All",
112
- RunFilter::Active => "Active",
113
- RunFilter::Completed => "Completed",
114
- RunFilter::Failed => "Failed",
115
- }
116
- }
117
-
118
- pub fn matches(&self, status: &RunStatus) -> bool {
119
- match self {
120
- RunFilter::All => true,
121
- RunFilter::Active => matches!(status, RunStatus::Pending | RunStatus::Running),
122
- RunFilter::Completed => matches!(status, RunStatus::Completed),
123
- RunFilter::Failed => matches!(status, RunStatus::Failed),
124
- }
125
- }
126
- }
127
-
128
- #[derive(Debug, Clone, PartialEq)]
129
- pub enum RunSort {
130
- DateDesc, // Newest first
131
- DateAsc, // Oldest first
132
- Status,
133
- Agent,
134
- }
135
-
136
- impl RunSort {
137
- pub fn as_str(&self) -> &str {
138
- match self {
139
- RunSort::DateDesc => "Date ",
140
- RunSort::DateAsc => "Date ↑",
141
- RunSort::Status => "Status",
142
- RunSort::Agent => "Agent",
143
- }
144
- }
145
- }
146
-
147
- impl Default for RunManagerState {
148
- fn default() -> Self {
149
- Self {
150
- runs: Vec::new(),
151
- selected_index: 0,
152
- filter: RunFilter::All,
153
- sort: RunSort::DateDesc,
154
- loading: false,
155
- last_refresh: None,
156
- detail_view: None,
157
- }
158
- }
159
- }
160
-
161
- /// Map a Gateway `/api/runs` JSON object into UI state.
162
- pub fn run_info_from_gateway_value(v: &Value) -> Option<RunInfo> {
163
- let o = v.as_object()?;
164
- let id = o.get("id")?.as_str()?.to_string();
165
- let name = o
166
- .get("name")
167
- .and_then(|n| n.as_str())
168
- .unwrap_or("Run")
169
- .to_string();
170
- let status_str = o
171
- .get("status")
172
- .and_then(|s| s.as_str())
173
- .unwrap_or("created");
174
- let status = match status_str {
175
- "running" => RunStatus::Running,
176
- "completed" => RunStatus::Completed,
177
- "failed" => RunStatus::Failed,
178
- "killed" => RunStatus::Cancelled,
179
- _ => RunStatus::Pending,
180
- };
181
- let created_at_sort = o
182
- .get("createdAt")
183
- .and_then(|c| c.as_str())
184
- .unwrap_or("")
185
- .to_string();
186
- let started_at = o
187
- .get("startedAt")
188
- .and_then(|s| s.as_str())
189
- .filter(|s| !s.is_empty())
190
- .map(|s| s.chars().take(19).collect())
191
- .unwrap_or_else(|| {
192
- created_at_sort
193
- .chars()
194
- .take(19)
195
- .collect::<String>()
196
- });
197
- let agent_name = extract_agent_name(o);
198
- let logs: Vec<String> = o
199
- .get("logs")
200
- .and_then(|l| l.as_array())
201
- .map(|arr| {
202
- arr.iter()
203
- .filter_map(|e| {
204
- let lo = e.as_object()?;
205
- let ts = lo
206
- .get("timestamp")
207
- .and_then(|t| t.as_str())
208
- .unwrap_or("");
209
- let msg = lo
210
- .get("message")
211
- .and_then(|m| m.as_str())
212
- .unwrap_or("");
213
- Some(format!("[{}] {}", ts, msg))
214
- })
215
- .collect()
216
- })
217
- .unwrap_or_default();
218
- let error_message = o
219
- .get("output")
220
- .and_then(|out| out.as_object())
221
- .and_then(|oo| oo.get("error"))
222
- .and_then(|e| e.as_str())
223
- .map(|s| s.to_string());
224
- let model = o
225
- .get("input")
226
- .and_then(|i| i.as_object())
227
- .and_then(|io| io.get("_agent"))
228
- .and_then(|a| a.as_object())
229
- .and_then(|ao| ao.get("model"))
230
- .and_then(|m| m.as_str())
231
- .map(|s| s.to_string());
232
- let progress = match status {
233
- RunStatus::Completed => Some(1.0),
234
- RunStatus::Failed | RunStatus::Cancelled => None,
235
- RunStatus::Running => Some(0.5),
236
- RunStatus::Pending => Some(0.05),
237
- };
238
-
239
- Some(RunInfo {
240
- id,
241
- name,
242
- agent_name,
243
- status,
244
- created_at_sort,
245
- started_at,
246
- duration: None,
247
- progress,
248
- input_tokens: None,
249
- output_tokens: None,
250
- total_cost: None,
251
- model,
252
- error_message,
253
- logs,
254
- })
255
- }
256
-
257
- fn extract_agent_name(o: &serde_json::Map<String, Value>) -> String {
258
- if let Some(tags) = o.get("tags").and_then(|t| t.as_array()) {
259
- for t in tags {
260
- if let Some(s) = t.as_str() {
261
- if let Some(rest) = s.strip_prefix("agent:") {
262
- return rest.to_string();
263
- }
264
- }
265
- }
266
- }
267
- if let Some(agent) = o
268
- .get("input")
269
- .and_then(|i| i.as_object())
270
- .and_then(|io| io.get("_agent"))
271
- .and_then(|a| a.as_object())
272
- {
273
- if let Some(n) = agent.get("name").and_then(|v| v.as_str()) {
274
- return n.to_string();
275
- }
276
- }
277
- if let Some(aid) = o
278
- .get("input")
279
- .and_then(|i| i.as_object())
280
- .and_then(|io| io.get("agent_id"))
281
- .and_then(|v| v.as_str())
282
- {
283
- return aid.to_string();
284
- }
285
- "—".to_string()
286
- }
287
-
288
- impl RunManagerState {
289
- pub fn replace_runs(&mut self, runs: Vec<RunInfo>) {
290
- self.runs = runs;
291
- self.selected_index = 0;
292
- self.detail_view = None;
293
- self.loading = false;
294
- self.last_refresh = Some(std::time::Instant::now());
295
- }
296
-
297
- pub fn merge_run_detail(&mut self, info: RunInfo) {
298
- if let Some(i) = self.runs.iter().position(|r| r.id == info.id) {
299
- self.runs[i] = info;
300
- } else {
301
- self.runs.push(info);
302
- }
303
- self.loading = false;
304
- }
305
-
306
- /// Get filtered and sorted runs
307
- pub fn filtered_runs(&self) -> Vec<&RunInfo> {
308
- let mut runs: Vec<&RunInfo> = self.runs.iter()
309
- .filter(|r| self.filter.matches(&r.status))
310
- .collect();
311
-
312
- // Sort
313
- match self.sort {
314
- RunSort::DateDesc => {
315
- runs.sort_by(|a, b| b.created_at_sort.cmp(&a.created_at_sort));
316
- }
317
- RunSort::DateAsc => {
318
- runs.sort_by(|a, b| a.created_at_sort.cmp(&b.created_at_sort));
319
- }
320
- RunSort::Status => {
321
- runs.sort_by_key(|r| r.status.as_str());
322
- }
323
- RunSort::Agent => {
324
- runs.sort_by_key(|r| &r.agent_name);
325
- }
326
- }
327
-
328
- runs
329
- }
330
-
331
- /// Get currently selected run
332
- pub fn selected_run(&self) -> Option<&RunInfo> {
333
- let filtered = self.filtered_runs();
334
- filtered.get(self.selected_index).copied()
335
- }
336
-
337
- /// Toggle detail view for selected run
338
- pub fn toggle_detail_view(&mut self) {
339
- if self.detail_view.is_some() {
340
- // Close detail view
341
- self.detail_view = None;
342
- } else {
343
- // Open detail view for selected run
344
- self.detail_view = Some(self.selected_index);
345
- }
346
- }
347
-
348
- /// Close detail view
349
- pub fn close_detail_view(&mut self) {
350
- self.detail_view = None;
351
- }
352
-
353
- /// Check if in detail view
354
- pub fn is_detail_view(&self) -> bool {
355
- self.detail_view.is_some()
356
- }
357
-
358
- /// Move selection up
359
- pub fn select_previous(&mut self) {
360
- if self.selected_index > 0 {
361
- self.selected_index -= 1;
362
- }
363
- }
364
-
365
- /// Move selection down
366
- pub fn select_next(&mut self) {
367
- let filtered_count = self.filtered_runs().len();
368
- if self.selected_index + 1 < filtered_count {
369
- self.selected_index += 1;
370
- }
371
- }
372
-
373
- /// Cycle to next filter
374
- pub fn next_filter(&mut self) {
375
- self.filter = match self.filter {
376
- RunFilter::All => RunFilter::Active,
377
- RunFilter::Active => RunFilter::Completed,
378
- RunFilter::Completed => RunFilter::Failed,
379
- RunFilter::Failed => RunFilter::All,
380
- };
381
- self.selected_index = 0; // Reset selection
382
- }
383
-
384
- /// Cycle to next sort
385
- pub fn next_sort(&mut self) {
386
- self.sort = match self.sort {
387
- RunSort::DateDesc => RunSort::DateAsc,
388
- RunSort::DateAsc => RunSort::Status,
389
- RunSort::Status => RunSort::Agent,
390
- RunSort::Agent => RunSort::DateDesc,
391
- };
392
- }
393
- }
394
-
395
- /// Render the Run Manager screen
396
- pub fn render(f: &mut Frame, state: &AppState) {
397
- let area = f.size();
398
-
399
- // Get run manager state from AppState
400
- let run_state = &state.run_manager;
401
-
402
- // Check if in detail view
403
- if run_state.is_detail_view() {
404
- render_detail_view(f, area, run_state);
405
- } else {
406
- // Split screen into sections
407
- use ratatui::layout::{Constraint, Direction, Layout};
408
-
409
- let chunks = Layout::default()
410
- .direction(Direction::Vertical)
411
- .constraints([
412
- Constraint::Length(3), // Header
413
- Constraint::Min(10), // Run list
414
- Constraint::Length(8), // Details panel
415
- Constraint::Length(3), // Actions
416
- ])
417
- .split(area);
418
-
419
- render_header(f, chunks[0], run_state);
420
- render_run_list(f, chunks[1], run_state);
421
- render_details_panel(f, chunks[2], run_state);
422
- render_actions(f, chunks[3]);
423
- }
424
- }
425
-
426
- fn render_header(f: &mut Frame, area: Rect, state: &RunManagerState) {
427
- let filtered_count = state.filtered_runs().len();
428
- let total_count = state.runs.len();
429
-
430
- let block = Block::default()
431
- .title(format!(
432
- " ⚡ Run Manager - {} ({}/{} runs) ",
433
- state.filter.as_str(),
434
- filtered_count,
435
- total_count
436
- ))
437
- .borders(Borders::ALL)
438
- .border_style(Style::default().fg(BRAND_PURPLE))
439
- .style(Style::default().bg(BG_PANEL));
440
-
441
- f.render_widget(block, area);
442
- }
443
-
444
- fn render_run_list(f: &mut Frame, area: Rect, state: &RunManagerState) {
445
- let block = Block::default()
446
- .title(format!(" 📋 Runs (Sort: {}) ", state.sort.as_str()))
447
- .borders(Borders::ALL)
448
- .border_style(Style::default().fg(CYBER_CYAN))
449
- .style(Style::default().bg(BG_PANEL));
450
-
451
- let inner = block.inner(area);
452
- f.render_widget(block, area);
453
-
454
- let filtered_runs = state.filtered_runs();
455
-
456
- if filtered_runs.is_empty() {
457
- let text = Paragraph::new("No runs found")
458
- .style(Style::default().fg(Color::DarkGray))
459
- .alignment(Alignment::Center);
460
- f.render_widget(text, inner);
461
- return;
462
- }
463
-
464
- let items: Vec<ListItem> = filtered_runs.iter().enumerate().map(|(i, run)| {
465
- let is_selected = i == state.selected_index;
466
-
467
- let progress_bar = if let Some(progress) = run.progress {
468
- let filled = (progress * 20.0) as usize;
469
- let empty = 20 - filled;
470
- format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
471
- } else {
472
- "[░░░░░░░░░░░░░░░░░░░░]".to_string()
473
- };
474
-
475
- let duration_str = run.duration.as_deref().unwrap_or("--");
476
-
477
- // Build line with colored spans
478
- let line = Line::from(vec![
479
- Span::styled(
480
- if is_selected { " " } else { " " },
481
- Style::default().fg(BRAND_PURPLE).bold()
482
- ),
483
- Span::styled(
484
- format!("{:<10}", run.status.as_str()),
485
- Style::default().fg(run.status.color()).bold()
486
- ),
487
- Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
488
- Span::styled(
489
- format!("{:<20}", run.name),
490
- Style::default().fg(if is_selected { CYBER_CYAN } else { TEXT_PRIMARY }).bold()
491
- ),
492
- Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
493
- Span::styled(
494
- format!("{:<15}", run.agent_name),
495
- Style::default().fg(TEXT_DIM)
496
- ),
497
- Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
498
- Span::styled(
499
- format!("{:>8}", duration_str),
500
- Style::default().fg(TEXT_MUTED)
501
- ),
502
- Span::raw(" "),
503
- Span::styled(
504
- progress_bar,
505
- Style::default().fg(if run.progress.unwrap_or(0.0) > 0.5 { NEON_GREEN } else { AMBER_WARN })
506
- ),
507
- ]);
508
-
509
- ListItem::new(line)
510
- }).collect();
511
-
512
- let list = List::new(items);
513
- f.render_widget(list, inner);
514
- }
515
-
516
- fn render_details_panel(f: &mut Frame, area: Rect, state: &RunManagerState) {
517
- let block = Block::default()
518
- .title(" 🔍 Run Details ")
519
- .borders(Borders::ALL)
520
- .border_style(Style::default().fg(NEON_GREEN))
521
- .style(Style::default().bg(BG_PANEL));
522
-
523
- let inner = block.inner(area);
524
- f.render_widget(block, area);
525
-
526
- if let Some(run) = state.selected_run() {
527
- let text = vec![
528
- Line::from(vec![
529
- Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
530
- Span::styled(&run.id, Style::default().fg(AMBER_WARN)),
531
- ]),
532
- Line::from(vec![
533
- Span::styled("Name: ", Style::default().fg(TEXT_DIM)),
534
- Span::styled(&run.name, Style::default().fg(TEXT_PRIMARY).bold()),
535
- ]),
536
- Line::from(vec![
537
- Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
538
- Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN)),
539
- ]),
540
- Line::from(vec![
541
- Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
542
- Span::styled(run.status.as_str(), Style::default().fg(run.status.color()).bold()),
543
- ]),
544
- Line::from(vec![
545
- Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
546
- Span::styled(&run.started_at, Style::default().fg(TEXT_MUTED)),
547
- ]),
548
- ];
549
-
550
- let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
551
- f.render_widget(paragraph, inner);
552
- } else {
553
- let text = Paragraph::new("No run selected")
554
- .style(Style::default().fg(TEXT_DIM))
555
- .alignment(Alignment::Center);
556
- f.render_widget(text, inner);
557
- }
558
- }
559
-
560
- fn render_detail_view(f: &mut Frame, area: Rect, state: &RunManagerState) {
561
- use ratatui::layout::{Constraint, Direction, Layout};
562
-
563
- // Split into header, content, and footer
564
- let chunks = Layout::default()
565
- .direction(Direction::Vertical)
566
- .constraints([
567
- Constraint::Length(3), // Header
568
- Constraint::Min(10), // Content
569
- Constraint::Length(3), // Footer
570
- ])
571
- .split(area);
572
-
573
- // Get the selected run
574
- let run = match state.selected_run() {
575
- Some(r) => r,
576
- None => {
577
- let block = Block::default()
578
- .title(" Run Details ")
579
- .borders(Borders::ALL)
580
- .border_style(Style::default().fg(TEXT_DIM));
581
- f.render_widget(block, area);
582
- return;
583
- }
584
- };
585
-
586
- // Header
587
- let header_block = Block::default()
588
- .title(format!(" 📊 Run Details - {} ", run.name))
589
- .borders(Borders::ALL)
590
- .border_style(Style::default().fg(BRAND_PURPLE))
591
- .style(Style::default().bg(BG_PANEL));
592
- f.render_widget(header_block, chunks[0]);
593
-
594
- // Content - split into left (info) and right (logs)
595
- let content_chunks = Layout::default()
596
- .direction(Direction::Horizontal)
597
- .constraints([
598
- Constraint::Percentage(40), // Info
599
- Constraint::Percentage(60), // Logs
600
- ])
601
- .split(chunks[1]);
602
-
603
- // Left: Run Info
604
- let info_block = Block::default()
605
- .title(" ℹ️ Information ")
606
- .borders(Borders::ALL)
607
- .border_style(Style::default().fg(CYBER_CYAN))
608
- .style(Style::default().bg(BG_PANEL));
609
-
610
- let info_inner = info_block.inner(content_chunks[0]);
611
- f.render_widget(info_block, content_chunks[0]);
612
-
613
- let mut info_lines = vec![
614
- Line::from(""),
615
- Line::from(vec![
616
- Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
617
- Span::styled(&run.id, Style::default().fg(TEXT_PRIMARY)),
618
- ]),
619
- Line::from(vec![
620
- Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
621
- Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN).bold()),
622
- ]),
623
- Line::from(vec![
624
- Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
625
- Span::styled(run.status.as_str(), Style::default().fg(run.status.color()).bold()),
626
- ]),
627
- Line::from(""),
628
- Line::from(vec![
629
- Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
630
- Span::styled(&run.started_at, Style::default().fg(TEXT_PRIMARY)),
631
- ]),
632
- Line::from(vec![
633
- Span::styled("Duration: ", Style::default().fg(TEXT_DIM)),
634
- Span::styled(run.duration.as_deref().unwrap_or("--"), Style::default().fg(TEXT_PRIMARY)),
635
- ]),
636
- Line::from(""),
637
- ];
638
-
639
- // Add model info if available
640
- if let Some(model) = &run.model {
641
- info_lines.push(Line::from(vec![
642
- Span::styled("Model: ", Style::default().fg(TEXT_DIM)),
643
- Span::styled(model, Style::default().fg(TEXT_PRIMARY)),
644
- ]));
645
- }
646
-
647
- // Add token usage if available
648
- if let (Some(input), Some(output)) = (run.input_tokens, run.output_tokens) {
649
- info_lines.push(Line::from(""));
650
- info_lines.push(Line::from("Token Usage:").style(Style::default().fg(NEON_GREEN).bold()));
651
- info_lines.push(Line::from(vec![
652
- Span::styled(" Input: ", Style::default().fg(TEXT_DIM)),
653
- Span::styled(format!("{}", input), Style::default().fg(TEXT_PRIMARY)),
654
- ]));
655
- info_lines.push(Line::from(vec![
656
- Span::styled(" Output: ", Style::default().fg(TEXT_DIM)),
657
- Span::styled(format!("{}", output), Style::default().fg(TEXT_PRIMARY)),
658
- ]));
659
- info_lines.push(Line::from(vec![
660
- Span::styled(" Total: ", Style::default().fg(TEXT_DIM)),
661
- Span::styled(format!("{}", input + output), Style::default().fg(NEON_GREEN).bold()),
662
- ]));
663
- }
664
-
665
- // Add cost if available
666
- if let Some(cost) = run.total_cost {
667
- info_lines.push(Line::from(""));
668
- info_lines.push(Line::from(vec![
669
- Span::styled("Cost: ", Style::default().fg(TEXT_DIM)),
670
- Span::styled(format!("${:.4}", cost), Style::default().fg(AMBER_WARN).bold()),
671
- ]));
672
- }
673
-
674
- // Add error if present
675
- if let Some(error) = &run.error_message {
676
- info_lines.push(Line::from(""));
677
- info_lines.push(Line::from("Error:").style(Style::default().fg(Color::Rgb(255, 69, 69)).bold()));
678
- info_lines.push(Line::from(format!(" {}", error)).style(Style::default().fg(Color::Rgb(255, 69, 69))));
679
- }
680
-
681
- let info_paragraph = Paragraph::new(info_lines)
682
- .wrap(Wrap { trim: false });
683
- f.render_widget(info_paragraph, info_inner);
684
-
685
- // Right: Logs
686
- let logs_block = Block::default()
687
- .title(" 📜 Execution Logs ")
688
- .borders(Borders::ALL)
689
- .border_style(Style::default().fg(NEON_GREEN))
690
- .style(Style::default().bg(BG_PANEL));
691
-
692
- let logs_inner = logs_block.inner(content_chunks[1]);
693
- f.render_widget(logs_block, content_chunks[1]);
694
-
695
- let log_lines: Vec<Line> = if run.logs.is_empty() {
696
- vec![
697
- Line::from(""),
698
- Line::from("No logs available").style(Style::default().fg(TEXT_DIM)),
699
- ]
700
- } else {
701
- run.logs.iter().map(|log| {
702
- Line::from(log.as_str()).style(Style::default().fg(TEXT_PRIMARY))
703
- }).collect()
704
- };
705
-
706
- let logs_paragraph = Paragraph::new(log_lines)
707
- .wrap(Wrap { trim: false });
708
- f.render_widget(logs_paragraph, logs_inner);
709
-
710
- // Footer
711
- let footer_block = Block::default()
712
- .title(" ⌨️ Actions ")
713
- .borders(Borders::ALL)
714
- .border_style(Style::default().fg(TEXT_DIM))
715
- .style(Style::default().bg(BG_PANEL));
716
-
717
- let footer_inner = footer_block.inner(chunks[2]);
718
- f.render_widget(footer_block, chunks[2]);
719
-
720
- let footer_text = Line::from(vec![
721
- Span::styled("ESC/Enter", Style::default().fg(BRAND_PURPLE).bold()),
722
- Span::styled(" Back to List │ ", Style::default().fg(TEXT_DIM)),
723
- Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
724
- Span::styled(" Refresh", Style::default().fg(TEXT_DIM)),
725
- ]);
726
-
727
- let footer_paragraph = Paragraph::new(footer_text)
728
- .alignment(Alignment::Center);
729
- f.render_widget(footer_paragraph, footer_inner);
730
- }
731
-
732
- fn render_actions(f: &mut Frame, area: Rect) {
733
- let block = Block::default()
734
- .title(" ⌨️ Actions ")
735
- .borders(Borders::ALL)
736
- .border_style(Style::default().fg(TEXT_DIM))
737
- .style(Style::default().bg(BG_PANEL));
738
-
739
- let inner = block.inner(area);
740
- f.render_widget(block, area);
741
-
742
- let text = Line::from(vec![
743
- Span::styled("↑/↓", Style::default().fg(CYBER_CYAN).bold()),
744
- Span::styled(" Select │ ", Style::default().fg(TEXT_DIM)),
745
- Span::styled("F", Style::default().fg(AMBER_WARN).bold()),
746
- Span::styled(" Filter │ ", Style::default().fg(TEXT_DIM)),
747
- Span::styled("S", Style::default().fg(AMBER_WARN).bold()),
748
- Span::styled(" Sort │ ", Style::default().fg(TEXT_DIM)),
749
- Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
750
- Span::styled(" Refresh │ ", Style::default().fg(TEXT_DIM)),
751
- Span::styled("C", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
752
- Span::styled(" Cancel │ ", Style::default().fg(TEXT_DIM)),
753
- Span::styled("D", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
754
- Span::styled(" Delete ", Style::default().fg(TEXT_DIM)),
755
- Span::styled("ESC", Style::default().fg(BRAND_PURPLE).bold()),
756
- Span::styled(" Close", Style::default().fg(TEXT_DIM)),
757
- ]);
758
-
759
- let paragraph = Paragraph::new(text)
760
- .style(Style::default().fg(TEXT_PRIMARY))
761
- .alignment(Alignment::Center);
762
-
763
- f.render_widget(paragraph, inner);
764
- }
1
+ use crate::app::AppState;
2
+ /// Run Manager Screen
3
+ /// View, filter, and manage agent runs
4
+ use ratatui::prelude::*;
5
+ use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
6
+ use serde_json::Value;
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
+ #[allow(dead_code)]
35
+ pub loading: bool,
36
+
37
+ /// Last refresh time
38
+ #[allow(dead_code)]
39
+ pub last_refresh: Option<std::time::Instant>,
40
+
41
+ /// Detailed view state (None = list view, Some(index) = detail view)
42
+ pub detail_view: Option<usize>,
43
+ }
44
+
45
+ #[derive(Debug, Clone)]
46
+ pub struct RunInfo {
47
+ pub id: String,
48
+ pub name: String,
49
+ pub agent_name: String,
50
+ pub status: RunStatus,
51
+ /// ISO `createdAt` from gateway (for sorting)
52
+ pub created_at_sort: String,
53
+ pub started_at: String,
54
+ pub duration: Option<String>,
55
+ pub progress: Option<f32>, // 0.0-1.0
56
+
57
+ // Extended details
58
+ pub input_tokens: Option<u32>,
59
+ pub output_tokens: Option<u32>,
60
+ pub total_cost: Option<f64>,
61
+ pub model: Option<String>,
62
+ pub error_message: Option<String>,
63
+ pub logs: Vec<String>,
64
+ }
65
+
66
+ #[derive(Debug, Clone, PartialEq)]
67
+ pub enum RunStatus {
68
+ #[allow(dead_code)]
69
+ Pending,
70
+ Running,
71
+ Completed,
72
+ Failed,
73
+ #[allow(dead_code)]
74
+ Cancelled,
75
+ }
76
+
77
+ impl RunStatus {
78
+ pub fn as_str(&self) -> &str {
79
+ match self {
80
+ RunStatus::Pending => "PENDING",
81
+ RunStatus::Running => "RUNNING",
82
+ RunStatus::Completed => "COMPLETED",
83
+ RunStatus::Failed => "FAILED",
84
+ RunStatus::Cancelled => "CANCELLED",
85
+ }
86
+ }
87
+
88
+ pub fn color(&self) -> Color {
89
+ match self {
90
+ RunStatus::Pending => AMBER_WARN,
91
+ RunStatus::Running => CYBER_CYAN,
92
+ RunStatus::Completed => NEON_GREEN,
93
+ RunStatus::Failed => Color::Rgb(255, 69, 69), // Bright red
94
+ RunStatus::Cancelled => TEXT_DIM,
95
+ }
96
+ }
97
+ }
98
+
99
+ #[derive(Debug, Clone, PartialEq)]
100
+ pub enum RunFilter {
101
+ All,
102
+ Active, // Pending + Running
103
+ Completed,
104
+ Failed,
105
+ }
106
+
107
+ impl RunFilter {
108
+ pub fn as_str(&self) -> &str {
109
+ match self {
110
+ RunFilter::All => "All",
111
+ RunFilter::Active => "Active",
112
+ RunFilter::Completed => "Completed",
113
+ RunFilter::Failed => "Failed",
114
+ }
115
+ }
116
+
117
+ pub fn matches(&self, status: &RunStatus) -> bool {
118
+ match self {
119
+ RunFilter::All => true,
120
+ RunFilter::Active => matches!(status, RunStatus::Pending | RunStatus::Running),
121
+ RunFilter::Completed => matches!(status, RunStatus::Completed),
122
+ RunFilter::Failed => matches!(status, RunStatus::Failed),
123
+ }
124
+ }
125
+ }
126
+
127
+ #[derive(Debug, Clone, PartialEq)]
128
+ pub enum RunSort {
129
+ DateDesc, // Newest first
130
+ DateAsc, // Oldest first
131
+ Status,
132
+ Agent,
133
+ }
134
+
135
+ impl RunSort {
136
+ pub fn as_str(&self) -> &str {
137
+ match self {
138
+ RunSort::DateDesc => "Date ↓",
139
+ RunSort::DateAsc => "Date ",
140
+ RunSort::Status => "Status",
141
+ RunSort::Agent => "Agent",
142
+ }
143
+ }
144
+ }
145
+
146
+ impl Default for RunManagerState {
147
+ fn default() -> Self {
148
+ Self {
149
+ runs: Vec::new(),
150
+ selected_index: 0,
151
+ filter: RunFilter::All,
152
+ sort: RunSort::DateDesc,
153
+ loading: false,
154
+ last_refresh: None,
155
+ detail_view: None,
156
+ }
157
+ }
158
+ }
159
+
160
+ /// Map a Gateway `/api/runs` JSON object into UI state.
161
+ pub fn run_info_from_gateway_value(v: &Value) -> Option<RunInfo> {
162
+ let o = v.as_object()?;
163
+ let id = o.get("id")?.as_str()?.to_string();
164
+ let name = o
165
+ .get("name")
166
+ .and_then(|n| n.as_str())
167
+ .unwrap_or("Run")
168
+ .to_string();
169
+ let status_str = o
170
+ .get("status")
171
+ .and_then(|s| s.as_str())
172
+ .unwrap_or("created");
173
+ let status = match status_str {
174
+ "running" => RunStatus::Running,
175
+ "completed" => RunStatus::Completed,
176
+ "failed" => RunStatus::Failed,
177
+ "killed" => RunStatus::Cancelled,
178
+ _ => RunStatus::Pending,
179
+ };
180
+ let created_at_sort = o
181
+ .get("createdAt")
182
+ .and_then(|c| c.as_str())
183
+ .unwrap_or("")
184
+ .to_string();
185
+ let started_at = o
186
+ .get("startedAt")
187
+ .and_then(|s| s.as_str())
188
+ .filter(|s| !s.is_empty())
189
+ .map(|s| s.chars().take(19).collect())
190
+ .unwrap_or_else(|| created_at_sort.chars().take(19).collect::<String>());
191
+ let agent_name = extract_agent_name(o);
192
+ let logs: Vec<String> = o
193
+ .get("logs")
194
+ .and_then(|l| l.as_array())
195
+ .map(|arr| {
196
+ arr.iter()
197
+ .filter_map(|e| {
198
+ let lo = e.as_object()?;
199
+ let ts = lo.get("timestamp").and_then(|t| t.as_str()).unwrap_or("");
200
+ let msg = lo.get("message").and_then(|m| m.as_str()).unwrap_or("");
201
+ Some(format!("[{}] {}", ts, msg))
202
+ })
203
+ .collect()
204
+ })
205
+ .unwrap_or_default();
206
+ let error_message = o
207
+ .get("output")
208
+ .and_then(|out| out.as_object())
209
+ .and_then(|oo| oo.get("error"))
210
+ .and_then(|e| e.as_str())
211
+ .map(|s| s.to_string());
212
+ let model = o
213
+ .get("input")
214
+ .and_then(|i| i.as_object())
215
+ .and_then(|io| io.get("_agent"))
216
+ .and_then(|a| a.as_object())
217
+ .and_then(|ao| ao.get("model"))
218
+ .and_then(|m| m.as_str())
219
+ .map(|s| s.to_string());
220
+ let progress = match status {
221
+ RunStatus::Completed => Some(1.0),
222
+ RunStatus::Failed | RunStatus::Cancelled => None,
223
+ RunStatus::Running => Some(0.5),
224
+ RunStatus::Pending => Some(0.05),
225
+ };
226
+
227
+ Some(RunInfo {
228
+ id,
229
+ name,
230
+ agent_name,
231
+ status,
232
+ created_at_sort,
233
+ started_at,
234
+ duration: None,
235
+ progress,
236
+ input_tokens: None,
237
+ output_tokens: None,
238
+ total_cost: None,
239
+ model,
240
+ error_message,
241
+ logs,
242
+ })
243
+ }
244
+
245
+ fn extract_agent_name(o: &serde_json::Map<String, Value>) -> String {
246
+ if let Some(tags) = o.get("tags").and_then(|t| t.as_array()) {
247
+ for t in tags {
248
+ if let Some(s) = t.as_str() {
249
+ if let Some(rest) = s.strip_prefix("agent:") {
250
+ return rest.to_string();
251
+ }
252
+ }
253
+ }
254
+ }
255
+ if let Some(agent) = o
256
+ .get("input")
257
+ .and_then(|i| i.as_object())
258
+ .and_then(|io| io.get("_agent"))
259
+ .and_then(|a| a.as_object())
260
+ {
261
+ if let Some(n) = agent.get("name").and_then(|v| v.as_str()) {
262
+ return n.to_string();
263
+ }
264
+ }
265
+ if let Some(aid) = o
266
+ .get("input")
267
+ .and_then(|i| i.as_object())
268
+ .and_then(|io| io.get("agent_id"))
269
+ .and_then(|v| v.as_str())
270
+ {
271
+ return aid.to_string();
272
+ }
273
+ "".to_string()
274
+ }
275
+
276
+ impl RunManagerState {
277
+ pub fn replace_runs(&mut self, runs: Vec<RunInfo>) {
278
+ self.runs = runs;
279
+ self.selected_index = 0;
280
+ self.detail_view = None;
281
+ self.loading = false;
282
+ self.last_refresh = Some(std::time::Instant::now());
283
+ }
284
+
285
+ pub fn merge_run_detail(&mut self, info: RunInfo) {
286
+ if let Some(i) = self.runs.iter().position(|r| r.id == info.id) {
287
+ self.runs[i] = info;
288
+ } else {
289
+ self.runs.push(info);
290
+ }
291
+ self.loading = false;
292
+ }
293
+
294
+ /// Get filtered and sorted runs
295
+ pub fn filtered_runs(&self) -> Vec<&RunInfo> {
296
+ let mut runs: Vec<&RunInfo> = self
297
+ .runs
298
+ .iter()
299
+ .filter(|r| self.filter.matches(&r.status))
300
+ .collect();
301
+
302
+ // Sort
303
+ match self.sort {
304
+ RunSort::DateDesc => {
305
+ runs.sort_by(|a, b| b.created_at_sort.cmp(&a.created_at_sort));
306
+ }
307
+ RunSort::DateAsc => {
308
+ runs.sort_by(|a, b| a.created_at_sort.cmp(&b.created_at_sort));
309
+ }
310
+ RunSort::Status => {
311
+ runs.sort_by_key(|r| r.status.as_str());
312
+ }
313
+ RunSort::Agent => {
314
+ runs.sort_by_key(|r| &r.agent_name);
315
+ }
316
+ }
317
+
318
+ runs
319
+ }
320
+
321
+ /// Get currently selected run
322
+ pub fn selected_run(&self) -> Option<&RunInfo> {
323
+ let filtered = self.filtered_runs();
324
+ filtered.get(self.selected_index).copied()
325
+ }
326
+
327
+ /// Toggle detail view for selected run
328
+ pub fn toggle_detail_view(&mut self) {
329
+ if self.detail_view.is_some() {
330
+ // Close detail view
331
+ self.detail_view = None;
332
+ } else {
333
+ // Open detail view for selected run
334
+ self.detail_view = Some(self.selected_index);
335
+ }
336
+ }
337
+
338
+ /// Close detail view
339
+ pub fn close_detail_view(&mut self) {
340
+ self.detail_view = None;
341
+ }
342
+
343
+ /// Check if in detail view
344
+ pub fn is_detail_view(&self) -> bool {
345
+ self.detail_view.is_some()
346
+ }
347
+
348
+ /// Move selection up
349
+ pub fn select_previous(&mut self) {
350
+ if self.selected_index > 0 {
351
+ self.selected_index -= 1;
352
+ }
353
+ }
354
+
355
+ /// Move selection down
356
+ pub fn select_next(&mut self) {
357
+ let filtered_count = self.filtered_runs().len();
358
+ if self.selected_index + 1 < filtered_count {
359
+ self.selected_index += 1;
360
+ }
361
+ }
362
+
363
+ /// Cycle to next filter
364
+ pub fn next_filter(&mut self) {
365
+ self.filter = match self.filter {
366
+ RunFilter::All => RunFilter::Active,
367
+ RunFilter::Active => RunFilter::Completed,
368
+ RunFilter::Completed => RunFilter::Failed,
369
+ RunFilter::Failed => RunFilter::All,
370
+ };
371
+ self.selected_index = 0; // Reset selection
372
+ }
373
+
374
+ /// Cycle to next sort
375
+ pub fn next_sort(&mut self) {
376
+ self.sort = match self.sort {
377
+ RunSort::DateDesc => RunSort::DateAsc,
378
+ RunSort::DateAsc => RunSort::Status,
379
+ RunSort::Status => RunSort::Agent,
380
+ RunSort::Agent => RunSort::DateDesc,
381
+ };
382
+ }
383
+ }
384
+
385
+ /// Render the Run Manager screen
386
+ pub fn render(f: &mut Frame, state: &AppState) {
387
+ let area = f.size();
388
+
389
+ // Get run manager state from AppState
390
+ let run_state = &state.run_manager;
391
+
392
+ // Check if in detail view
393
+ if run_state.is_detail_view() {
394
+ render_detail_view(f, area, run_state);
395
+ } else {
396
+ // Split screen into sections
397
+ use ratatui::layout::{Constraint, Direction, Layout};
398
+
399
+ let chunks = Layout::default()
400
+ .direction(Direction::Vertical)
401
+ .constraints([
402
+ Constraint::Length(3), // Header
403
+ Constraint::Min(10), // Run list
404
+ Constraint::Length(8), // Details panel
405
+ Constraint::Length(3), // Actions
406
+ ])
407
+ .split(area);
408
+
409
+ render_header(f, chunks[0], run_state);
410
+ render_run_list(f, chunks[1], run_state);
411
+ render_details_panel(f, chunks[2], run_state);
412
+ render_actions(f, chunks[3]);
413
+ }
414
+ }
415
+
416
+ fn render_header(f: &mut Frame, area: Rect, state: &RunManagerState) {
417
+ let filtered_count = state.filtered_runs().len();
418
+ let total_count = state.runs.len();
419
+
420
+ let block = Block::default()
421
+ .title(format!(
422
+ " ⚡ Run Manager - {} ({}/{} runs) ",
423
+ state.filter.as_str(),
424
+ filtered_count,
425
+ total_count
426
+ ))
427
+ .borders(Borders::ALL)
428
+ .border_style(Style::default().fg(BRAND_PURPLE))
429
+ .style(Style::default().bg(BG_PANEL));
430
+
431
+ f.render_widget(block, area);
432
+ }
433
+
434
+ fn render_run_list(f: &mut Frame, area: Rect, state: &RunManagerState) {
435
+ let block = Block::default()
436
+ .title(format!(" 📋 Runs (Sort: {}) ", state.sort.as_str()))
437
+ .borders(Borders::ALL)
438
+ .border_style(Style::default().fg(CYBER_CYAN))
439
+ .style(Style::default().bg(BG_PANEL));
440
+
441
+ let inner = block.inner(area);
442
+ f.render_widget(block, area);
443
+
444
+ let filtered_runs = state.filtered_runs();
445
+
446
+ if filtered_runs.is_empty() {
447
+ let text = Paragraph::new("No runs found")
448
+ .style(Style::default().fg(Color::DarkGray))
449
+ .alignment(Alignment::Center);
450
+ f.render_widget(text, inner);
451
+ return;
452
+ }
453
+
454
+ let items: Vec<ListItem> = filtered_runs
455
+ .iter()
456
+ .enumerate()
457
+ .map(|(i, run)| {
458
+ let is_selected = i == state.selected_index;
459
+
460
+ let progress_bar = if let Some(progress) = run.progress {
461
+ let filled = (progress * 20.0) as usize;
462
+ let empty = 20 - filled;
463
+ format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
464
+ } else {
465
+ "[░░░░░░░░░░░░░░░░░░░░]".to_string()
466
+ };
467
+
468
+ let duration_str = run.duration.as_deref().unwrap_or("--");
469
+
470
+ // Build line with colored spans
471
+ let line = Line::from(vec![
472
+ Span::styled(
473
+ if is_selected { "▶ " } else { " " },
474
+ Style::default().fg(BRAND_PURPLE).bold(),
475
+ ),
476
+ Span::styled(
477
+ format!("{:<10}", run.status.as_str()),
478
+ Style::default().fg(run.status.color()).bold(),
479
+ ),
480
+ Span::styled(" ", Style::default().fg(TEXT_DIM)),
481
+ Span::styled(
482
+ format!("{:<20}", run.name),
483
+ Style::default()
484
+ .fg(if is_selected {
485
+ CYBER_CYAN
486
+ } else {
487
+ TEXT_PRIMARY
488
+ })
489
+ .bold(),
490
+ ),
491
+ Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
492
+ Span::styled(
493
+ format!("{:<15}", run.agent_name),
494
+ Style::default().fg(TEXT_DIM),
495
+ ),
496
+ Span::styled(" │ ", Style::default().fg(TEXT_DIM)),
497
+ Span::styled(
498
+ format!("{:>8}", duration_str),
499
+ Style::default().fg(TEXT_MUTED),
500
+ ),
501
+ Span::raw(" "),
502
+ Span::styled(
503
+ progress_bar,
504
+ Style::default().fg(if run.progress.unwrap_or(0.0) > 0.5 {
505
+ NEON_GREEN
506
+ } else {
507
+ AMBER_WARN
508
+ }),
509
+ ),
510
+ ]);
511
+
512
+ ListItem::new(line)
513
+ })
514
+ .collect();
515
+
516
+ let list = List::new(items);
517
+ f.render_widget(list, inner);
518
+ }
519
+
520
+ fn render_details_panel(f: &mut Frame, area: Rect, state: &RunManagerState) {
521
+ let block = Block::default()
522
+ .title(" 🔍 Run Details ")
523
+ .borders(Borders::ALL)
524
+ .border_style(Style::default().fg(NEON_GREEN))
525
+ .style(Style::default().bg(BG_PANEL));
526
+
527
+ let inner = block.inner(area);
528
+ f.render_widget(block, area);
529
+
530
+ if let Some(run) = state.selected_run() {
531
+ let text = vec![
532
+ Line::from(vec![
533
+ Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
534
+ Span::styled(&run.id, Style::default().fg(AMBER_WARN)),
535
+ ]),
536
+ Line::from(vec![
537
+ Span::styled("Name: ", Style::default().fg(TEXT_DIM)),
538
+ Span::styled(&run.name, Style::default().fg(TEXT_PRIMARY).bold()),
539
+ ]),
540
+ Line::from(vec![
541
+ Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
542
+ Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN)),
543
+ ]),
544
+ Line::from(vec![
545
+ Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
546
+ Span::styled(
547
+ run.status.as_str(),
548
+ Style::default().fg(run.status.color()).bold(),
549
+ ),
550
+ ]),
551
+ Line::from(vec![
552
+ Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
553
+ Span::styled(&run.started_at, Style::default().fg(TEXT_MUTED)),
554
+ ]),
555
+ ];
556
+
557
+ let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
558
+ f.render_widget(paragraph, inner);
559
+ } else {
560
+ let text = Paragraph::new("No run selected")
561
+ .style(Style::default().fg(TEXT_DIM))
562
+ .alignment(Alignment::Center);
563
+ f.render_widget(text, inner);
564
+ }
565
+ }
566
+
567
+ fn render_detail_view(f: &mut Frame, area: Rect, state: &RunManagerState) {
568
+ use ratatui::layout::{Constraint, Direction, Layout};
569
+
570
+ // Split into header, content, and footer
571
+ let chunks = Layout::default()
572
+ .direction(Direction::Vertical)
573
+ .constraints([
574
+ Constraint::Length(3), // Header
575
+ Constraint::Min(10), // Content
576
+ Constraint::Length(3), // Footer
577
+ ])
578
+ .split(area);
579
+
580
+ // Get the selected run
581
+ let run = match state.selected_run() {
582
+ Some(r) => r,
583
+ None => {
584
+ let block = Block::default()
585
+ .title(" Run Details ")
586
+ .borders(Borders::ALL)
587
+ .border_style(Style::default().fg(TEXT_DIM));
588
+ f.render_widget(block, area);
589
+ return;
590
+ }
591
+ };
592
+
593
+ // Header
594
+ let header_block = Block::default()
595
+ .title(format!(" 📊 Run Details - {} ", run.name))
596
+ .borders(Borders::ALL)
597
+ .border_style(Style::default().fg(BRAND_PURPLE))
598
+ .style(Style::default().bg(BG_PANEL));
599
+ f.render_widget(header_block, chunks[0]);
600
+
601
+ // Content - split into left (info) and right (logs)
602
+ let content_chunks = Layout::default()
603
+ .direction(Direction::Horizontal)
604
+ .constraints([
605
+ Constraint::Percentage(40), // Info
606
+ Constraint::Percentage(60), // Logs
607
+ ])
608
+ .split(chunks[1]);
609
+
610
+ // Left: Run Info
611
+ let info_block = Block::default()
612
+ .title(" ℹ️ Information ")
613
+ .borders(Borders::ALL)
614
+ .border_style(Style::default().fg(CYBER_CYAN))
615
+ .style(Style::default().bg(BG_PANEL));
616
+
617
+ let info_inner = info_block.inner(content_chunks[0]);
618
+ f.render_widget(info_block, content_chunks[0]);
619
+
620
+ let mut info_lines = vec![
621
+ Line::from(""),
622
+ Line::from(vec![
623
+ Span::styled("ID: ", Style::default().fg(TEXT_DIM)),
624
+ Span::styled(&run.id, Style::default().fg(TEXT_PRIMARY)),
625
+ ]),
626
+ Line::from(vec![
627
+ Span::styled("Agent: ", Style::default().fg(TEXT_DIM)),
628
+ Span::styled(&run.agent_name, Style::default().fg(CYBER_CYAN).bold()),
629
+ ]),
630
+ Line::from(vec![
631
+ Span::styled("Status: ", Style::default().fg(TEXT_DIM)),
632
+ Span::styled(
633
+ run.status.as_str(),
634
+ Style::default().fg(run.status.color()).bold(),
635
+ ),
636
+ ]),
637
+ Line::from(""),
638
+ Line::from(vec![
639
+ Span::styled("Started: ", Style::default().fg(TEXT_DIM)),
640
+ Span::styled(&run.started_at, Style::default().fg(TEXT_PRIMARY)),
641
+ ]),
642
+ Line::from(vec![
643
+ Span::styled("Duration: ", Style::default().fg(TEXT_DIM)),
644
+ Span::styled(
645
+ run.duration.as_deref().unwrap_or("--"),
646
+ Style::default().fg(TEXT_PRIMARY),
647
+ ),
648
+ ]),
649
+ Line::from(""),
650
+ ];
651
+
652
+ // Add model info if available
653
+ if let Some(model) = &run.model {
654
+ info_lines.push(Line::from(vec![
655
+ Span::styled("Model: ", Style::default().fg(TEXT_DIM)),
656
+ Span::styled(model, Style::default().fg(TEXT_PRIMARY)),
657
+ ]));
658
+ }
659
+
660
+ // Add token usage if available
661
+ if let (Some(input), Some(output)) = (run.input_tokens, run.output_tokens) {
662
+ info_lines.push(Line::from(""));
663
+ info_lines.push(Line::from("Token Usage:").style(Style::default().fg(NEON_GREEN).bold()));
664
+ info_lines.push(Line::from(vec![
665
+ Span::styled(" Input: ", Style::default().fg(TEXT_DIM)),
666
+ Span::styled(format!("{}", input), Style::default().fg(TEXT_PRIMARY)),
667
+ ]));
668
+ info_lines.push(Line::from(vec![
669
+ Span::styled(" Output: ", Style::default().fg(TEXT_DIM)),
670
+ Span::styled(format!("{}", output), Style::default().fg(TEXT_PRIMARY)),
671
+ ]));
672
+ info_lines.push(Line::from(vec![
673
+ Span::styled(" Total: ", Style::default().fg(TEXT_DIM)),
674
+ Span::styled(
675
+ format!("{}", input + output),
676
+ Style::default().fg(NEON_GREEN).bold(),
677
+ ),
678
+ ]));
679
+ }
680
+
681
+ // Add cost if available
682
+ if let Some(cost) = run.total_cost {
683
+ info_lines.push(Line::from(""));
684
+ info_lines.push(Line::from(vec![
685
+ Span::styled("Cost: ", Style::default().fg(TEXT_DIM)),
686
+ Span::styled(
687
+ format!("${:.4}", cost),
688
+ Style::default().fg(AMBER_WARN).bold(),
689
+ ),
690
+ ]));
691
+ }
692
+
693
+ // Add error if present
694
+ if let Some(error) = &run.error_message {
695
+ info_lines.push(Line::from(""));
696
+ info_lines
697
+ .push(Line::from("Error:").style(Style::default().fg(Color::Rgb(255, 69, 69)).bold()));
698
+ info_lines.push(
699
+ Line::from(format!(" {}", error)).style(Style::default().fg(Color::Rgb(255, 69, 69))),
700
+ );
701
+ }
702
+
703
+ let info_paragraph = Paragraph::new(info_lines).wrap(Wrap { trim: false });
704
+ f.render_widget(info_paragraph, info_inner);
705
+
706
+ // Right: Logs
707
+ let logs_block = Block::default()
708
+ .title(" 📜 Execution Logs ")
709
+ .borders(Borders::ALL)
710
+ .border_style(Style::default().fg(NEON_GREEN))
711
+ .style(Style::default().bg(BG_PANEL));
712
+
713
+ let logs_inner = logs_block.inner(content_chunks[1]);
714
+ f.render_widget(logs_block, content_chunks[1]);
715
+
716
+ let log_lines: Vec<Line> = if run.logs.is_empty() {
717
+ vec![
718
+ Line::from(""),
719
+ Line::from("No logs available").style(Style::default().fg(TEXT_DIM)),
720
+ ]
721
+ } else {
722
+ run.logs
723
+ .iter()
724
+ .map(|log| Line::from(log.as_str()).style(Style::default().fg(TEXT_PRIMARY)))
725
+ .collect()
726
+ };
727
+
728
+ let logs_paragraph = Paragraph::new(log_lines).wrap(Wrap { trim: false });
729
+ f.render_widget(logs_paragraph, logs_inner);
730
+
731
+ // Footer
732
+ let footer_block = Block::default()
733
+ .title(" ⌨️ Actions ")
734
+ .borders(Borders::ALL)
735
+ .border_style(Style::default().fg(TEXT_DIM))
736
+ .style(Style::default().bg(BG_PANEL));
737
+
738
+ let footer_inner = footer_block.inner(chunks[2]);
739
+ f.render_widget(footer_block, chunks[2]);
740
+
741
+ let footer_text = Line::from(vec![
742
+ Span::styled("ESC/Enter", Style::default().fg(BRAND_PURPLE).bold()),
743
+ Span::styled(" Back to List │ ", Style::default().fg(TEXT_DIM)),
744
+ Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
745
+ Span::styled(" Refresh", Style::default().fg(TEXT_DIM)),
746
+ ]);
747
+
748
+ let footer_paragraph = Paragraph::new(footer_text).alignment(Alignment::Center);
749
+ f.render_widget(footer_paragraph, footer_inner);
750
+ }
751
+
752
+ fn render_actions(f: &mut Frame, area: Rect) {
753
+ let block = Block::default()
754
+ .title(" ⌨️ Actions ")
755
+ .borders(Borders::ALL)
756
+ .border_style(Style::default().fg(TEXT_DIM))
757
+ .style(Style::default().bg(BG_PANEL));
758
+
759
+ let inner = block.inner(area);
760
+ f.render_widget(block, area);
761
+
762
+ let text = Line::from(vec![
763
+ Span::styled("↑/↓", Style::default().fg(CYBER_CYAN).bold()),
764
+ Span::styled(" Select │ ", Style::default().fg(TEXT_DIM)),
765
+ Span::styled("F", Style::default().fg(AMBER_WARN).bold()),
766
+ Span::styled(" Filter │ ", Style::default().fg(TEXT_DIM)),
767
+ Span::styled("S", Style::default().fg(AMBER_WARN).bold()),
768
+ Span::styled(" Sort │ ", Style::default().fg(TEXT_DIM)),
769
+ Span::styled("R", Style::default().fg(NEON_GREEN).bold()),
770
+ Span::styled(" Refresh │ ", Style::default().fg(TEXT_DIM)),
771
+ Span::styled("C", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
772
+ Span::styled(" Cancel │ ", Style::default().fg(TEXT_DIM)),
773
+ Span::styled("D", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
774
+ Span::styled(" Delete │ ", Style::default().fg(TEXT_DIM)),
775
+ Span::styled("ESC", Style::default().fg(BRAND_PURPLE).bold()),
776
+ Span::styled(" Close", Style::default().fg(TEXT_DIM)),
777
+ ]);
778
+
779
+ let paragraph = Paragraph::new(text)
780
+ .style(Style::default().fg(TEXT_PRIMARY))
781
+ .alignment(Alignment::Center);
782
+
783
+ f.render_widget(paragraph, inner);
784
+ }