4runr-os-mk3 0.1.0 → 0.1.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/Cargo.lock +1105 -0
- package/Cargo.toml +16 -0
- package/bin/mk3-tui.js +21 -7
- package/install.js +40 -14
- package/package.json +6 -3
- package/src/app/render_scheduler.rs +103 -0
- package/src/app.rs +435 -0
- package/src/io/mod.rs +66 -0
- package/src/io/protocol.rs +15 -0
- package/src/io/stdio.rs +32 -0
- package/src/io/ws.rs +32 -0
- package/src/main.rs +119 -0
- package/src/ui/boot.rs +150 -0
- package/src/ui/layout.rs +705 -0
- package/src/ui/mod.rs +4 -0
- package/src/ui/safe_viewport.rs +235 -0
package/src/ui/layout.rs
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
use crate::app::AppState;
|
|
2
|
+
use crate::ui::safe_viewport::SafeViewport;
|
|
3
|
+
use ratatui::prelude::*;
|
|
4
|
+
use ratatui::widgets::*;
|
|
5
|
+
use ratatui::style::Modifier;
|
|
6
|
+
use ratatui::text::{Line, Span};
|
|
7
|
+
|
|
8
|
+
// === 4RUNR BRAND COLORS ===
|
|
9
|
+
const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
|
|
10
|
+
const BRAND_VIOLET: Color = Color::Rgb(148, 103, 189);
|
|
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
|
+
#[allow(dead_code)]
|
|
18
|
+
const BG_PANEL: Color = Color::Rgb(18, 18, 25);
|
|
19
|
+
|
|
20
|
+
// === ANIMATION CONSTANTS ===
|
|
21
|
+
const SPINNERS: [&str; 8] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
|
|
22
|
+
// NOTE: PULSE removed - we use static dots (*, +, -) for status indicators
|
|
23
|
+
// to prevent flashing when typing. Instructions: "DON'T use animated pulse for status dots!"
|
|
24
|
+
|
|
25
|
+
const MIN_COLS: u16 = 80;
|
|
26
|
+
const MIN_ROWS: u16 = 24;
|
|
27
|
+
|
|
28
|
+
/// Render a horizontal separator line that stops before right edge
|
|
29
|
+
fn render_separator(f: &mut Frame, area: Rect) {
|
|
30
|
+
// Stop separator at 85% of terminal width (safe zone)
|
|
31
|
+
let safe_width = (area.width * 85 / 100).min(area.width.saturating_sub(10));
|
|
32
|
+
|
|
33
|
+
if safe_width > 0 {
|
|
34
|
+
let separator_line = "─".repeat(safe_width as usize);
|
|
35
|
+
f.render_widget(
|
|
36
|
+
Paragraph::new(separator_line).style(Style::default().fg(TEXT_DIM)),
|
|
37
|
+
Rect { x: area.x, y: area.y, width: safe_width, height: 1 }
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pub fn render(f: &mut Frame, state: &AppState) {
|
|
43
|
+
let full_area = f.size();
|
|
44
|
+
f.render_widget(Clear, full_area);
|
|
45
|
+
|
|
46
|
+
let viewport = SafeViewport::new(full_area);
|
|
47
|
+
|
|
48
|
+
if viewport.is_too_small(MIN_COLS, MIN_ROWS) {
|
|
49
|
+
render_too_small(f, &viewport);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let safe = viewport.safe_rect;
|
|
54
|
+
|
|
55
|
+
// Dark background
|
|
56
|
+
f.render_widget(
|
|
57
|
+
Block::default().style(Style::default().bg(Color::Rgb(10, 10, 15))),
|
|
58
|
+
full_area
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Main layout: Header (3) + Content (flex) + Command (4)
|
|
62
|
+
let main_chunks = Layout::default()
|
|
63
|
+
.direction(Direction::Vertical)
|
|
64
|
+
.constraints([
|
|
65
|
+
Constraint::Length(3), // Header
|
|
66
|
+
Constraint::Min(15), // Content
|
|
67
|
+
Constraint::Length(4), // Command bar
|
|
68
|
+
])
|
|
69
|
+
.split(safe);
|
|
70
|
+
|
|
71
|
+
render_header(f, main_chunks[0], state);
|
|
72
|
+
|
|
73
|
+
// === ADD SEPARATOR BELOW HEADER ===
|
|
74
|
+
if main_chunks[1].y > main_chunks[0].y + main_chunks[0].height {
|
|
75
|
+
render_separator(f, Rect {
|
|
76
|
+
x: safe.x,
|
|
77
|
+
y: main_chunks[0].y + main_chunks[0].height,
|
|
78
|
+
width: safe.width,
|
|
79
|
+
height: 1,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
render_content(f, main_chunks[1], state);
|
|
84
|
+
|
|
85
|
+
// === ADD SEPARATOR ABOVE COMMAND BOX ===
|
|
86
|
+
if main_chunks[2].y > main_chunks[1].y + main_chunks[1].height {
|
|
87
|
+
render_separator(f, Rect {
|
|
88
|
+
x: safe.x,
|
|
89
|
+
y: main_chunks[1].y + main_chunks[1].height,
|
|
90
|
+
width: safe.width,
|
|
91
|
+
height: 1,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
render_command_box(f, main_chunks[2], state);
|
|
96
|
+
|
|
97
|
+
// Perf overlay (if enabled)
|
|
98
|
+
if state.perf_overlay {
|
|
99
|
+
render_perf_overlay(f, full_area, state);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn render_header(f: &mut Frame, area: Rect, state: &AppState) {
|
|
104
|
+
// Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
|
|
105
|
+
let max_width = (area.width * 85 / 100).max(10); // At least 10 chars, max 85% of width
|
|
106
|
+
let header_area = Rect {
|
|
107
|
+
x: area.x + 2,
|
|
108
|
+
y: area.y,
|
|
109
|
+
width: max_width.saturating_sub(2), // Additional 2 char margin
|
|
110
|
+
height: 3,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let spinner = SPINNERS[state.spinner_frame];
|
|
114
|
+
|
|
115
|
+
// Format uptime
|
|
116
|
+
let uptime_str = format_uptime(state.uptime_secs);
|
|
117
|
+
|
|
118
|
+
// Line 1: Brand + uptime - Bug 3 fix: Use "4Runr." with dot (matches brand logo)
|
|
119
|
+
let brand_line = Line::from(vec![
|
|
120
|
+
Span::styled("4Runr.", Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
|
|
121
|
+
Span::styled(" AI AGENT OS", Style::default().fg(BRAND_VIOLET)),
|
|
122
|
+
Span::styled(format!(" {} UPTIME: {}", spinner, uptime_str),
|
|
123
|
+
Style::default().fg(TEXT_MUTED)),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// Line 2: Status bar - NO long lines that touch right edge (causes scrollbars!)
|
|
127
|
+
// Use short separators or just text
|
|
128
|
+
let status_line = Line::from(vec![
|
|
129
|
+
Span::styled(" ", Style::default()), // Indent to align
|
|
130
|
+
Span::styled("*", Style::default().fg(NEON_GREEN)),
|
|
131
|
+
Span::styled(" SYSTEM ONLINE ", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
|
|
132
|
+
Span::styled("*", Style::default().fg(NEON_GREEN)),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
f.render_widget(Paragraph::new(brand_line), Rect {
|
|
136
|
+
x: header_area.x, y: header_area.y, width: header_area.width, height: 1
|
|
137
|
+
});
|
|
138
|
+
f.render_widget(Paragraph::new(status_line), Rect {
|
|
139
|
+
x: header_area.x, y: header_area.y + 1, width: header_area.width, height: 1
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Bug 6 fix: Add separator line below header (but stop before right edge)
|
|
143
|
+
let max_sep_width = (header_area.width * 85 / 100).max(20); // Max 85% of width
|
|
144
|
+
let separator = "-".repeat(max_sep_width as usize);
|
|
145
|
+
f.render_widget(
|
|
146
|
+
Paragraph::new(Line::from(Span::styled(separator, Style::default().fg(TEXT_MUTED)))),
|
|
147
|
+
Rect {
|
|
148
|
+
x: header_area.x,
|
|
149
|
+
y: header_area.y + 2,
|
|
150
|
+
width: max_sep_width,
|
|
151
|
+
height: 1
|
|
152
|
+
}
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn format_uptime(secs: u64) -> String {
|
|
157
|
+
let hours = secs / 3600;
|
|
158
|
+
let mins = (secs % 3600) / 60;
|
|
159
|
+
let secs = secs % 60;
|
|
160
|
+
|
|
161
|
+
if hours > 0 {
|
|
162
|
+
format!("{}h {}m {}s", hours, mins, secs)
|
|
163
|
+
} else if mins > 0 {
|
|
164
|
+
format!("{}m {}s", mins, secs)
|
|
165
|
+
} else {
|
|
166
|
+
format!("{}s", secs)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fn render_content(f: &mut Frame, area: Rect, state: &AppState) {
|
|
171
|
+
// Two-column layout: Left (system) + Right (logs & capabilities)
|
|
172
|
+
let cols = Layout::default()
|
|
173
|
+
.direction(Direction::Horizontal)
|
|
174
|
+
.constraints([
|
|
175
|
+
Constraint::Percentage(35), // Left panel
|
|
176
|
+
Constraint::Percentage(65), // Right panel
|
|
177
|
+
])
|
|
178
|
+
.split(area);
|
|
179
|
+
|
|
180
|
+
render_left_column(f, cols[0], state);
|
|
181
|
+
|
|
182
|
+
// Right column: Operations Log + Capabilities
|
|
183
|
+
// Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
|
|
184
|
+
let max_width = (cols[1].width * 85 / 100).max(10); // At least 10 chars, max 85% of width
|
|
185
|
+
let right_chunks = Layout::default()
|
|
186
|
+
.direction(Direction::Vertical)
|
|
187
|
+
.constraints([
|
|
188
|
+
Constraint::Percentage(70), // Operations log (main focus)
|
|
189
|
+
Constraint::Percentage(30), // Capabilities
|
|
190
|
+
])
|
|
191
|
+
.split(Rect {
|
|
192
|
+
x: cols[1].x,
|
|
193
|
+
y: cols[1].y,
|
|
194
|
+
width: max_width.saturating_sub(2), // Additional 2 char margin for safety
|
|
195
|
+
height: cols[1].height,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
render_center_column(f, right_chunks[0], state);
|
|
199
|
+
|
|
200
|
+
// === ADD SEPARATOR BETWEEN OPERATIONS LOG AND CAPABILITIES ===
|
|
201
|
+
// IMPORTANT: Stop separator well before right edge (don't go too far on x-axis)
|
|
202
|
+
if right_chunks[1].y > right_chunks[0].y + right_chunks[0].height {
|
|
203
|
+
// Use the operations log panel width, not the full column width
|
|
204
|
+
let ops_panel_width = right_chunks[0].width;
|
|
205
|
+
let safe_sep_width = (ops_panel_width * 80 / 100).min(ops_panel_width.saturating_sub(15)); // Stop well before right edge
|
|
206
|
+
render_separator(f, Rect {
|
|
207
|
+
x: right_chunks[0].x,
|
|
208
|
+
y: right_chunks[0].y + right_chunks[0].height,
|
|
209
|
+
width: safe_sep_width,
|
|
210
|
+
height: 1,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
render_right_column(f, right_chunks[1], state);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fn render_left_column(f: &mut Frame, area: Rect, state: &AppState) {
|
|
218
|
+
let panel_area = Rect {
|
|
219
|
+
x: area.x + 1,
|
|
220
|
+
y: area.y,
|
|
221
|
+
width: area.width.saturating_sub(2),
|
|
222
|
+
height: area.height,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Split into System Status and Resources
|
|
226
|
+
let chunks = Layout::default()
|
|
227
|
+
.direction(Direction::Vertical)
|
|
228
|
+
.constraints([
|
|
229
|
+
Constraint::Percentage(70),
|
|
230
|
+
Constraint::Percentage(30),
|
|
231
|
+
])
|
|
232
|
+
.split(panel_area);
|
|
233
|
+
|
|
234
|
+
// === SYSTEM STATUS PANEL ===
|
|
235
|
+
render_panel_border(f, chunks[0], "SYSTEM STATUS", BRAND_PURPLE);
|
|
236
|
+
|
|
237
|
+
let status_content = inner_rect(chunks[0], 2);
|
|
238
|
+
let mut lines = vec![];
|
|
239
|
+
|
|
240
|
+
// Use STATIC dots for status indicators (not animated - prevents flashing when typing)
|
|
241
|
+
// Instructions: "DON'T use animated pulse for status dots - they flash weirdly when typing!"
|
|
242
|
+
|
|
243
|
+
// Posture status - Use ASCII * instead of Unicode ◉
|
|
244
|
+
let posture_color = if state.posture_status == "Healthy" || state.connected {
|
|
245
|
+
NEON_GREEN
|
|
246
|
+
} else {
|
|
247
|
+
AMBER_WARN
|
|
248
|
+
};
|
|
249
|
+
lines.push(Line::from(vec![
|
|
250
|
+
Span::styled("* ", Style::default().fg(posture_color)),
|
|
251
|
+
Span::styled("POSTURE ", Style::default().fg(TEXT_DIM)),
|
|
252
|
+
Span::styled(&state.posture_status, Style::default().fg(posture_color).add_modifier(Modifier::BOLD)),
|
|
253
|
+
]));
|
|
254
|
+
|
|
255
|
+
// Shield status
|
|
256
|
+
let shield_color = match state.shield_mode.as_str() {
|
|
257
|
+
"enforce" => NEON_GREEN,
|
|
258
|
+
"monitor" => AMBER_WARN,
|
|
259
|
+
_ => TEXT_MUTED,
|
|
260
|
+
};
|
|
261
|
+
lines.push(Line::from(vec![
|
|
262
|
+
Span::styled("* ", Style::default().fg(shield_color)),
|
|
263
|
+
Span::styled("SHIELD ", Style::default().fg(TEXT_DIM)),
|
|
264
|
+
Span::styled(state.shield_mode.to_uppercase(), Style::default().fg(shield_color).add_modifier(Modifier::BOLD)),
|
|
265
|
+
Span::styled(format!(" ({} active)", state.shield_detectors.len()), Style::default().fg(TEXT_DIM)),
|
|
266
|
+
]));
|
|
267
|
+
|
|
268
|
+
// Sentinel status
|
|
269
|
+
let sentinel_color = match state.sentinel_state.as_str() {
|
|
270
|
+
"watching" => CYBER_CYAN,
|
|
271
|
+
"triggered" => Color::Rgb(255, 69, 58),
|
|
272
|
+
_ => TEXT_DIM,
|
|
273
|
+
};
|
|
274
|
+
let mut sentinel_line = vec![
|
|
275
|
+
Span::styled("* ", Style::default().fg(sentinel_color)),
|
|
276
|
+
Span::styled("SENTINEL ", Style::default().fg(TEXT_DIM)),
|
|
277
|
+
Span::styled(state.sentinel_state.to_uppercase(), Style::default().fg(sentinel_color).add_modifier(Modifier::BOLD)),
|
|
278
|
+
];
|
|
279
|
+
if state.sentinel_active_runs > 0 {
|
|
280
|
+
sentinel_line.push(Span::styled(format!(" ({} runs)", state.sentinel_active_runs), Style::default().fg(TEXT_DIM)));
|
|
281
|
+
}
|
|
282
|
+
lines.push(Line::from(sentinel_line));
|
|
283
|
+
|
|
284
|
+
// Bug 6 fix: Add visual separator between sections (but stop before right edge)
|
|
285
|
+
// Use ASCII dashes, max 85% of width to avoid scrollbars
|
|
286
|
+
let max_sep_width = (status_content.width * 85 / 100).max(10).min(30); // Max 30 chars or 85% of width
|
|
287
|
+
let separator = "-".repeat(max_sep_width as usize);
|
|
288
|
+
lines.push(Line::from(Span::styled(separator, Style::default().fg(TEXT_MUTED))));
|
|
289
|
+
|
|
290
|
+
// Show enabled detectors with STATIC dots (not animated)
|
|
291
|
+
for detector in &state.shield_detectors {
|
|
292
|
+
let name = match detector.as_str() {
|
|
293
|
+
"pii" => "PII Detection",
|
|
294
|
+
"injection" => "Injection Block",
|
|
295
|
+
"hallucination" => "Hallucination Check",
|
|
296
|
+
_ => detector.as_str(),
|
|
297
|
+
};
|
|
298
|
+
lines.push(Line::from(vec![
|
|
299
|
+
Span::styled("* ", Style::default().fg(NEON_GREEN)), // Static asterisk
|
|
300
|
+
Span::styled(name, Style::default().fg(TEXT_PRIMARY)),
|
|
301
|
+
]));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Connection status
|
|
305
|
+
if !state.connected {
|
|
306
|
+
lines.push(Line::from(""));
|
|
307
|
+
lines.push(Line::from(vec![
|
|
308
|
+
Span::styled("* ", Style::default().fg(AMBER_WARN)), // Use ASCII * instead of Unicode ⚠
|
|
309
|
+
Span::styled("Demo Mode - Not connected", Style::default().fg(AMBER_WARN)),
|
|
310
|
+
]));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
f.render_widget(Paragraph::new(lines), status_content);
|
|
314
|
+
|
|
315
|
+
// Bug 6 fix: Add separator between System Status and Resources sections
|
|
316
|
+
let max_sep_width = (panel_area.width * 85 / 100).max(10).min(30);
|
|
317
|
+
let separator = "-".repeat(max_sep_width as usize);
|
|
318
|
+
let sep_y = chunks[0].y + chunks[0].height;
|
|
319
|
+
f.render_widget(
|
|
320
|
+
Paragraph::new(Line::from(Span::styled(separator, Style::default().fg(TEXT_MUTED)))),
|
|
321
|
+
Rect {
|
|
322
|
+
x: panel_area.x,
|
|
323
|
+
y: sep_y,
|
|
324
|
+
width: max_sep_width,
|
|
325
|
+
height: 1
|
|
326
|
+
}
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// === RESOURCES PANEL ===
|
|
330
|
+
render_panel_border(f, chunks[1], "RESOURCES", TEXT_DIM);
|
|
331
|
+
let resources_content = inner_rect(chunks[1], 2);
|
|
332
|
+
|
|
333
|
+
let network_color = if state.connected { NEON_GREEN } else { TEXT_MUTED };
|
|
334
|
+
|
|
335
|
+
let mut lines = vec![
|
|
336
|
+
render_progress_bar("CPU", state.cpu, cpu_color(state.cpu)),
|
|
337
|
+
render_progress_bar("MEM", state.mem, mem_color(state.mem)),
|
|
338
|
+
Line::from(""),
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
// Network info - Use ASCII * instead of Unicode ◉
|
|
342
|
+
lines.push(Line::from(vec![
|
|
343
|
+
Span::styled("NET ", Style::default().fg(TEXT_DIM)),
|
|
344
|
+
Span::styled("* ", Style::default().fg(network_color)),
|
|
345
|
+
Span::styled(&state.network_status, Style::default().fg(network_color)),
|
|
346
|
+
]));
|
|
347
|
+
if state.connected {
|
|
348
|
+
lines.push(Line::from(vec![
|
|
349
|
+
Span::styled(" Runs: ", Style::default().fg(TEXT_DIM)),
|
|
350
|
+
Span::styled(format!("{}", state.total_runs), Style::default().fg(TEXT_PRIMARY)),
|
|
351
|
+
]));
|
|
352
|
+
}
|
|
353
|
+
lines.push(Line::from(""));
|
|
354
|
+
lines.push(Line::from(vec![
|
|
355
|
+
Span::styled("Uptime: ", Style::default().fg(TEXT_DIM)),
|
|
356
|
+
Span::styled(format_uptime(state.uptime_secs), Style::default().fg(TEXT_PRIMARY)),
|
|
357
|
+
]));
|
|
358
|
+
|
|
359
|
+
f.render_widget(Paragraph::new(lines), resources_content);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fn render_panel_border(f: &mut Frame, area: Rect, title: &str, color: Color) {
|
|
363
|
+
// Top border with title - KEEP SHORT to avoid scrollbars!
|
|
364
|
+
// Instructions: "Only draw title, no long horizontal lines"
|
|
365
|
+
// Use ASCII characters that work everywhere
|
|
366
|
+
let title_line = Line::from(vec![
|
|
367
|
+
Span::styled("[ ", Style::default().fg(TEXT_MUTED)), // ASCII [ instead of Unicode ┌
|
|
368
|
+
Span::styled(title, Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
|
369
|
+
Span::styled(" ", Style::default()),
|
|
370
|
+
// Only short dash, not full width - instructions say this causes scrollbars!
|
|
371
|
+
Span::styled("-", Style::default().fg(TEXT_MUTED)), // Just a short dash, not full width
|
|
372
|
+
]);
|
|
373
|
+
// Make sure width doesn't touch right edge
|
|
374
|
+
let title_width = (title.len() as u16 + 4).min(area.width.saturating_sub(6));
|
|
375
|
+
f.render_widget(Paragraph::new(title_line), Rect {
|
|
376
|
+
x: area.x,
|
|
377
|
+
y: area.y,
|
|
378
|
+
width: title_width, // Only as wide as needed, not full width
|
|
379
|
+
height: 1
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// NO bottom border - instructions say it causes scrollbars!
|
|
383
|
+
// The visual separation comes from spacing and color
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fn inner_rect(area: Rect, padding: u16) -> Rect {
|
|
387
|
+
Rect {
|
|
388
|
+
x: area.x + padding,
|
|
389
|
+
y: area.y + 1,
|
|
390
|
+
width: area.width.saturating_sub(padding * 2 + 2),
|
|
391
|
+
height: area.height.saturating_sub(2),
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
fn render_progress_bar(label: &str, value: f64, color: Color) -> Line<'static> {
|
|
396
|
+
let bar_width = 12;
|
|
397
|
+
let filled = ((value * bar_width as f64) as usize).min(bar_width);
|
|
398
|
+
let empty = bar_width - filled;
|
|
399
|
+
|
|
400
|
+
Line::from(vec![
|
|
401
|
+
Span::styled(format!("{} ", label), Style::default().fg(TEXT_DIM)),
|
|
402
|
+
Span::styled("█".repeat(filled), Style::default().fg(color)),
|
|
403
|
+
Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)),
|
|
404
|
+
Span::styled(format!(" {:>3.0}%", value * 100.0), Style::default().fg(color)),
|
|
405
|
+
])
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fn cpu_color(value: f64) -> Color {
|
|
409
|
+
if value > 0.8 { Color::Rgb(255, 69, 58) } // Red
|
|
410
|
+
else if value > 0.5 { Color::Rgb(255, 191, 0) } // Amber
|
|
411
|
+
else { NEON_GREEN }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn mem_color(value: f64) -> Color {
|
|
415
|
+
if value > 0.8 { Color::Rgb(255, 69, 58) }
|
|
416
|
+
else if value > 0.6 { Color::Rgb(255, 191, 0) }
|
|
417
|
+
else { CYBER_CYAN }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
fn render_center_column(f: &mut Frame, area: Rect, state: &AppState) {
|
|
421
|
+
let panel_area = Rect {
|
|
422
|
+
x: area.x + 1,
|
|
423
|
+
y: area.y,
|
|
424
|
+
width: area.width.saturating_sub(2),
|
|
425
|
+
height: area.height,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// === OPERATIONS LOG - More contained/closed appearance ===
|
|
429
|
+
// Draw a proper box border for a more "closed up" look
|
|
430
|
+
let border_block = Block::default()
|
|
431
|
+
.title(" OPERATIONS LOG ")
|
|
432
|
+
.title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
|
|
433
|
+
.borders(Borders::ALL)
|
|
434
|
+
.border_style(Style::default().fg(TEXT_MUTED));
|
|
435
|
+
|
|
436
|
+
f.render_widget(border_block, panel_area);
|
|
437
|
+
|
|
438
|
+
// Content area with padding (inside the border)
|
|
439
|
+
let content_area = Rect {
|
|
440
|
+
x: panel_area.x + 1,
|
|
441
|
+
y: panel_area.y + 1,
|
|
442
|
+
width: panel_area.width.saturating_sub(4), // Leave room for border (2) + scrollbar (2)
|
|
443
|
+
height: panel_area.height.saturating_sub(2), // Top and bottom borders
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
let visible_height = content_area.height as usize;
|
|
447
|
+
let total_logs = state.logs.len();
|
|
448
|
+
let max_scroll = total_logs.saturating_sub(visible_height.max(1));
|
|
449
|
+
|
|
450
|
+
// Calculate which logs to show (scroll_pos = 0 means newest, higher = older)
|
|
451
|
+
let start_idx = state.log_scroll.min(max_scroll);
|
|
452
|
+
let _end_idx = (start_idx + visible_height).min(total_logs);
|
|
453
|
+
|
|
454
|
+
let spinner = SPINNERS[state.spinner_frame];
|
|
455
|
+
|
|
456
|
+
let mut lines = vec![
|
|
457
|
+
Line::from(vec![
|
|
458
|
+
Span::styled(spinner, Style::default().fg(CYBER_CYAN)),
|
|
459
|
+
Span::styled(" Monitoring...", Style::default().fg(TEXT_DIM).add_modifier(Modifier::ITALIC)),
|
|
460
|
+
]),
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
// Show real logs with proper formatting (reversed order - newest first)
|
|
464
|
+
for log in state.logs.iter().rev().skip(start_idx).take(visible_height) {
|
|
465
|
+
// Parse log format: [COMPONENT] message
|
|
466
|
+
if log.starts_with("[") {
|
|
467
|
+
if let Some(bracket_end) = log.find(']') {
|
|
468
|
+
let component = &log[1..bracket_end];
|
|
469
|
+
let message = log[bracket_end + 1..].trim();
|
|
470
|
+
|
|
471
|
+
let comp_color = match component {
|
|
472
|
+
"GATEWAY" => CYBER_CYAN,
|
|
473
|
+
"SHIELD" => BRAND_PURPLE,
|
|
474
|
+
"SENTINEL" => NEON_GREEN,
|
|
475
|
+
"WORKER" => AMBER_WARN,
|
|
476
|
+
"SYSTEM" => TEXT_DIM,
|
|
477
|
+
_ => TEXT_DIM,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
lines.push(Line::from(vec![
|
|
481
|
+
Span::styled("> ", Style::default().fg(comp_color)), // Use ASCII > instead of Unicode ▸
|
|
482
|
+
Span::styled(format!("[{}] ", component), Style::default().fg(comp_color)),
|
|
483
|
+
Span::styled(message, Style::default().fg(TEXT_PRIMARY)),
|
|
484
|
+
]));
|
|
485
|
+
} else {
|
|
486
|
+
lines.push(Line::from(Span::styled(log.as_str(), Style::default().fg(TEXT_PRIMARY))));
|
|
487
|
+
}
|
|
488
|
+
} else if log.starts_with(">") {
|
|
489
|
+
// Command echo
|
|
490
|
+
lines.push(Line::from(vec![
|
|
491
|
+
Span::styled("> ", Style::default().fg(BRAND_PURPLE)), // Use ASCII > instead of Unicode ▸
|
|
492
|
+
Span::styled(log, Style::default().fg(BRAND_PURPLE)),
|
|
493
|
+
]));
|
|
494
|
+
} else {
|
|
495
|
+
lines.push(Line::from(Span::styled(log.as_str(), Style::default().fg(TEXT_PRIMARY))));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Connection status at bottom
|
|
500
|
+
if !state.connected {
|
|
501
|
+
lines.push(Line::from(""));
|
|
502
|
+
lines.push(Line::from(vec![
|
|
503
|
+
Span::styled("* ", Style::default().fg(AMBER_WARN)), // Use ASCII * instead of Unicode ⚠
|
|
504
|
+
Span::styled("Demo Mode - Not connected to Gateway", Style::default().fg(AMBER_WARN)),
|
|
505
|
+
]));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
f.render_widget(Paragraph::new(lines), content_area);
|
|
509
|
+
|
|
510
|
+
// === RENDER CLEAN SCROLLBAR (only if scrollable) ===
|
|
511
|
+
if total_logs > visible_height && max_scroll > 0 {
|
|
512
|
+
// Scrollbar positioned inside the border, 1 char from right edge
|
|
513
|
+
let scrollbar_x = panel_area.x + panel_area.width.saturating_sub(3); // Inside border, 1 char from right
|
|
514
|
+
let scrollbar_y = content_area.y;
|
|
515
|
+
let scrollbar_height = content_area.height;
|
|
516
|
+
|
|
517
|
+
// Calculate scrollbar thumb size (proportional to visible/total ratio)
|
|
518
|
+
let thumb_height = ((visible_height as f32 / total_logs as f32) * scrollbar_height as f32).max(1.0) as u16;
|
|
519
|
+
let thumb_height = thumb_height.min(scrollbar_height);
|
|
520
|
+
|
|
521
|
+
// Calculate thumb position based on scroll offset
|
|
522
|
+
let scroll_ratio = if max_scroll > 0 { state.log_scroll as f32 / max_scroll as f32 } else { 0.0 };
|
|
523
|
+
let thumb_start = (scroll_ratio * (scrollbar_height - thumb_height) as f32) as u16;
|
|
524
|
+
|
|
525
|
+
// Draw clean scrollbar with better visibility
|
|
526
|
+
for i in 0..scrollbar_height {
|
|
527
|
+
let y = scrollbar_y + i;
|
|
528
|
+
if y < scrollbar_y + scrollbar_height {
|
|
529
|
+
let (c, color) = if i >= thumb_start && i < thumb_start + thumb_height {
|
|
530
|
+
("█", CYBER_CYAN) // Thumb in cyan (matches panel title)
|
|
531
|
+
} else {
|
|
532
|
+
("│", TEXT_MUTED) // Track in muted gray
|
|
533
|
+
};
|
|
534
|
+
f.render_widget(
|
|
535
|
+
Paragraph::new(c).style(Style::default().fg(color)),
|
|
536
|
+
Rect { x: scrollbar_x, y, width: 1, height: 1 }
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
fn render_right_column(f: &mut Frame, area: Rect, state: &AppState) {
|
|
544
|
+
// This now only renders CAPABILITIES (Network is shown in left panel)
|
|
545
|
+
let panel_area = Rect {
|
|
546
|
+
x: area.x + 1,
|
|
547
|
+
y: area.y,
|
|
548
|
+
width: area.width.saturating_sub(4), // Extra margin on right
|
|
549
|
+
height: area.height,
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// === CAPABILITIES PANEL ===
|
|
553
|
+
render_panel_border(f, panel_area, "CAPABILITIES", BRAND_PURPLE);
|
|
554
|
+
let caps_content = inner_rect(panel_area, 2);
|
|
555
|
+
|
|
556
|
+
// Use STATIC indicators (not animated pulse)
|
|
557
|
+
let mut cap_lines: Vec<Line> = vec![];
|
|
558
|
+
|
|
559
|
+
if state.capabilities.is_empty() {
|
|
560
|
+
cap_lines.push(Line::from(vec![
|
|
561
|
+
Span::styled("- ", Style::default().fg(TEXT_MUTED)),
|
|
562
|
+
Span::styled("No agents registered", Style::default().fg(TEXT_DIM)),
|
|
563
|
+
]));
|
|
564
|
+
cap_lines.push(Line::from(vec![
|
|
565
|
+
Span::styled(" ", Style::default()),
|
|
566
|
+
Span::styled("Use DevKit to register agents", Style::default().fg(TEXT_MUTED)),
|
|
567
|
+
]));
|
|
568
|
+
} else {
|
|
569
|
+
for cap in state.capabilities.iter().take(caps_content.height.saturating_sub(1) as usize) {
|
|
570
|
+
cap_lines.push(Line::from(vec![
|
|
571
|
+
Span::styled("+ ", Style::default().fg(BRAND_PURPLE)), // Static plus sign
|
|
572
|
+
Span::styled(cap.as_str(), Style::default().fg(TEXT_PRIMARY)),
|
|
573
|
+
Span::styled(" ", Style::default()),
|
|
574
|
+
Span::styled("* READY", Style::default().fg(NEON_GREEN)), // Static asterisk
|
|
575
|
+
]));
|
|
576
|
+
}
|
|
577
|
+
cap_lines.push(Line::from(vec![
|
|
578
|
+
Span::styled(format!("{} agents", state.capabilities.len()),
|
|
579
|
+
Style::default().fg(TEXT_DIM)),
|
|
580
|
+
]));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
f.render_widget(Paragraph::new(cap_lines), caps_content);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
fn render_command_box(f: &mut Frame, area: Rect, state: &AppState) {
|
|
588
|
+
// Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
|
|
589
|
+
let max_width = (area.width * 85 / 100).max(10); // At least 10 chars, max 85% of width
|
|
590
|
+
let bar_area = Rect {
|
|
591
|
+
x: area.x + 2,
|
|
592
|
+
y: area.y,
|
|
593
|
+
width: max_width.saturating_sub(2), // Additional 2 char margin
|
|
594
|
+
height: area.height,
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// NO separator line - instructions say it causes scrollbars!
|
|
598
|
+
// Just use spacing for visual separation
|
|
599
|
+
|
|
600
|
+
// Command prompt - Use ASCII > instead of Unicode ▶
|
|
601
|
+
let cursor = if state.command_focused || !state.command_input.is_empty() { "_" } else { "_" };
|
|
602
|
+
let prompt_line = Line::from(vec![
|
|
603
|
+
Span::styled("> ", Style::default().fg(BRAND_PURPLE)), // ASCII > instead of Unicode ▶
|
|
604
|
+
Span::styled("4runr", Style::default().fg(BRAND_VIOLET).add_modifier(Modifier::BOLD)),
|
|
605
|
+
Span::styled(": ", Style::default().fg(TEXT_DIM)), // ASCII : instead of Unicode ›
|
|
606
|
+
Span::styled(&state.command_input, Style::default().fg(TEXT_PRIMARY)),
|
|
607
|
+
Span::styled(cursor, Style::default().fg(CYBER_CYAN)),
|
|
608
|
+
]);
|
|
609
|
+
f.render_widget(Paragraph::new(prompt_line), Rect {
|
|
610
|
+
x: bar_area.x, y: bar_area.y + 1, width: bar_area.width, height: 1
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Help hints - Use ASCII | instead of Unicode │
|
|
614
|
+
let help_line = Line::from(vec![
|
|
615
|
+
Span::styled("Ctrl+C", Style::default().fg(BRAND_VIOLET)),
|
|
616
|
+
Span::styled(" exit | ", Style::default().fg(TEXT_MUTED)), // ASCII | instead of Unicode │
|
|
617
|
+
Span::styled("F10", Style::default().fg(BRAND_VIOLET)),
|
|
618
|
+
Span::styled(" quit | ", Style::default().fg(TEXT_MUTED)), // ASCII | instead of Unicode │
|
|
619
|
+
Span::styled("help", Style::default().fg(BRAND_VIOLET)),
|
|
620
|
+
Span::styled(" commands", Style::default().fg(TEXT_MUTED)),
|
|
621
|
+
]);
|
|
622
|
+
f.render_widget(Paragraph::new(help_line), Rect {
|
|
623
|
+
x: bar_area.x, y: bar_area.y + 2, width: bar_area.width, height: 1
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Cursor position when focused
|
|
627
|
+
if state.command_focused {
|
|
628
|
+
let cursor_x = bar_area.x + 8 + state.command_input.len() as u16; // +8 for "> 4runr: "
|
|
629
|
+
let cursor_y = bar_area.y + 1;
|
|
630
|
+
f.set_cursor(cursor_x.min(bar_area.x + bar_area.width - 1), cursor_y);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
fn render_too_small(f: &mut Frame, viewport: &SafeViewport) {
|
|
635
|
+
let msg = vec![
|
|
636
|
+
Line::from(Span::styled("4Runr.", Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))), // Bug 3 fix: Use "4Runr." with dot
|
|
637
|
+
Line::from(""),
|
|
638
|
+
Line::from(Span::styled("Terminal too small", Style::default().fg(Color::Rgb(255, 69, 58)))),
|
|
639
|
+
Line::from(""),
|
|
640
|
+
Line::from(Span::styled(
|
|
641
|
+
format!("Current: {}x{}", viewport.safe_cols, viewport.safe_rows),
|
|
642
|
+
Style::default().fg(TEXT_DIM)
|
|
643
|
+
)),
|
|
644
|
+
Line::from(Span::styled(
|
|
645
|
+
format!("Required: {}x{}", MIN_COLS, MIN_ROWS),
|
|
646
|
+
Style::default().fg(TEXT_PRIMARY)
|
|
647
|
+
)),
|
|
648
|
+
];
|
|
649
|
+
|
|
650
|
+
f.render_widget(
|
|
651
|
+
Paragraph::new(msg).alignment(Alignment::Center),
|
|
652
|
+
viewport.safe_rect
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
fn render_perf_overlay(f: &mut Frame, area: Rect, state: &AppState) {
|
|
657
|
+
// Calculate RPS from recent render durations
|
|
658
|
+
let rps = if !state.render_durations.is_empty() {
|
|
659
|
+
let avg_ms: f64 = state.render_durations.iter().sum::<u64>() as f64
|
|
660
|
+
/ state.render_durations.len() as f64;
|
|
661
|
+
if avg_ms > 0.0 {
|
|
662
|
+
(1000.0 / avg_ms) as u64
|
|
663
|
+
} else {
|
|
664
|
+
0
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
0
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
let last_render_ms = if !state.render_durations.is_empty() {
|
|
671
|
+
*state.render_durations.back().unwrap()
|
|
672
|
+
} else {
|
|
673
|
+
0
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
let overlay_text = format!(
|
|
677
|
+
"PERF OVERLAY (F12 to toggle)\n\
|
|
678
|
+
RPS: {} | Last render: {}ms | Total renders: {}\n\
|
|
679
|
+
Render scheduled: {} | Log writes: {}",
|
|
680
|
+
rps,
|
|
681
|
+
last_render_ms,
|
|
682
|
+
state.render_count,
|
|
683
|
+
state.render_scheduled_count,
|
|
684
|
+
state.log_write_count
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
let overlay_area = Rect {
|
|
688
|
+
x: area.width.saturating_sub(60),
|
|
689
|
+
y: 2,
|
|
690
|
+
width: 58,
|
|
691
|
+
height: 5,
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
let block = Block::default()
|
|
695
|
+
.borders(Borders::ALL)
|
|
696
|
+
.border_style(Style::default().fg(Color::Yellow))
|
|
697
|
+
.title(" Performance ");
|
|
698
|
+
|
|
699
|
+
let paragraph = Paragraph::new(overlay_text)
|
|
700
|
+
.block(block)
|
|
701
|
+
.style(Style::default().fg(Color::White))
|
|
702
|
+
.wrap(Wrap { trim: true });
|
|
703
|
+
|
|
704
|
+
f.render_widget(paragraph, overlay_area);
|
|
705
|
+
}
|