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.
- package/mk3-tui/src/app.rs +111 -47
- package/mk3-tui/src/main.rs +42 -7
- package/mk3-tui/src/screens/mod.rs +3 -1
- package/mk3-tui/src/storage/cache.rs +213 -213
- package/mk3-tui/src/storage/mod.rs +6 -6
- package/mk3-tui/src/ui/agent_builder.rs +921 -921
- package/mk3-tui/src/ui/agent_list.rs +247 -0
- package/mk3-tui/src/ui/help.rs +353 -0
- package/mk3-tui/src/ui/layout.rs +3 -3
- package/mk3-tui/src/ui/mod.rs +2 -0
- package/mk3-tui/src/ui/run_manager.rs +676 -676
- package/mk3-tui/src/ui/settings.rs +362 -362
- package/mk3-tui/src/websocket.rs +303 -303
- package/package.json +2 -1
|
@@ -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
|
+
}
|