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,803 +1,966 @@
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
- // CRITICAL: Hide cursor by default at start of each render
44
- // Only show it when explicitly set in render_command_box
45
- // This prevents cursor from appearing in wrong places (like operations log)
46
- let full_area = f.size();
47
- f.render_widget(Clear, full_area);
48
-
49
- let viewport = SafeViewport::new(full_area);
50
-
51
- if viewport.is_too_small(MIN_COLS, MIN_ROWS) {
52
- render_too_small(f, &viewport);
53
- return;
54
- }
55
-
56
- let safe = viewport.safe_rect;
57
-
58
- // Dark background
59
- f.render_widget(
60
- Block::default().style(Style::default().bg(Color::Rgb(10, 10, 15))),
61
- full_area
62
- );
63
-
64
- // Main layout: Header (3) + Content (flex) + Command (4)
65
- let main_chunks = Layout::default()
66
- .direction(Direction::Vertical)
67
- .constraints([
68
- Constraint::Length(3), // Header
69
- Constraint::Min(15), // Content
70
- Constraint::Length(4), // Command bar
71
- ])
72
- .split(safe);
73
-
74
- render_header(f, main_chunks[0], state);
75
-
76
- // === ADD SEPARATOR BELOW HEADER ===
77
- if main_chunks[1].y > main_chunks[0].y + main_chunks[0].height {
78
- render_separator(f, Rect {
79
- x: safe.x,
80
- y: main_chunks[0].y + main_chunks[0].height,
81
- width: safe.width,
82
- height: 1,
83
- });
84
- }
85
-
86
- render_content(f, main_chunks[1], state);
87
-
88
- // === ADD SEPARATOR ABOVE COMMAND BOX ===
89
- if main_chunks[2].y > main_chunks[1].y + main_chunks[1].height {
90
- render_separator(f, Rect {
91
- x: safe.x,
92
- y: main_chunks[1].y + main_chunks[1].height,
93
- width: safe.width,
94
- height: 1,
95
- });
96
- }
97
-
98
- render_command_box(f, main_chunks[2], state);
99
-
100
- // Perf overlay (if enabled)
101
- if state.perf_overlay {
102
- render_perf_overlay(f, full_area, state);
103
- }
104
- }
105
-
106
- fn render_header(f: &mut Frame, area: Rect, state: &AppState) {
107
- // Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
108
- let max_width = (area.width * 85 / 100).max(10); // At least 10 chars, max 85% of width
109
- let header_area = Rect {
110
- x: area.x + 2,
111
- y: area.y,
112
- width: max_width.saturating_sub(2), // Additional 2 char margin
113
- height: 3,
114
- };
115
-
116
- let spinner = SPINNERS[state.spinner_frame];
117
-
118
- // Format uptime
119
- let uptime_str = format_uptime(state.uptime_secs);
120
-
121
- // Operation mode indicator (Step 7)
122
- let mode_text = state.operation_mode.as_str();
123
- let mode_color = match state.operation_mode {
124
- crate::app::OperationMode::Local => AMBER_WARN,
125
- crate::app::OperationMode::Connected => NEON_GREEN,
126
- };
127
-
128
- // Connection status icon - Phase 1.1: Connection Visibility
129
- let connection_icon = if state.gateway_healthy && state.connected {
130
- "[●]" // Green dot - verified & healthy
131
- } else if state.connected {
132
- "[○]" // Yellow - connected but not verified
133
- } else {
134
- "[ ]" // Gray - not connected
135
- };
136
-
137
- let connection_color = if state.gateway_healthy && state.connected {
138
- NEON_GREEN
139
- } else if state.connected {
140
- AMBER_WARN
141
- } else {
142
- TEXT_MUTED
143
- };
144
-
145
- // Line 1: Brand + version + mode + uptime - Bug 3 fix: Use "4Runr." with dot (matches brand logo)
146
- // Version from env 4RUNR_CLI_VERSION (set by Node CLI at launch); fallback for dev without CLI
147
- let ver_env = std::env::var("4RUNR_CLI_VERSION").unwrap_or_else(|_| "2.9.69".to_string());
148
- let package_version = ver_env.trim();
149
- let package_version: &str = if package_version.is_empty() { "2.9.69" } else { package_version };
150
- let brand_line = Line::from(vec![
151
- Span::styled("4Runr.", Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
152
- Span::styled(" AI AGENT OS", Style::default().fg(BRAND_VIOLET)),
153
- Span::styled(format!(" v{}", package_version), Style::default().fg(TEXT_MUTED)),
154
- Span::styled(" Mode: ", Style::default().fg(TEXT_MUTED)),
155
- Span::styled(mode_text, Style::default().fg(mode_color).add_modifier(Modifier::BOLD)),
156
- Span::styled(" ", Style::default()),
157
- Span::styled(connection_icon, Style::default().fg(connection_color).add_modifier(Modifier::BOLD)),
158
- Span::styled(format!(" {} UPTIME: {}", spinner, uptime_str),
159
- Style::default().fg(TEXT_MUTED)),
160
- ]);
161
-
162
- // Line 2: Status bar with Gateway URL - Phase 1.1: Connection Visibility
163
- // NO long lines that touch right edge (causes scrollbars!)
164
- let mut status_spans = vec![
165
- Span::styled(" ", Style::default()), // Indent to align
166
- Span::styled("*", Style::default().fg(NEON_GREEN)),
167
- Span::styled(" SYSTEM ONLINE ", Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
168
- Span::styled("*", Style::default().fg(NEON_GREEN)),
169
- ];
170
-
171
- // Add Gateway info if connected
172
- if state.connected {
173
- if let Some(gateway_url) = &state.gateway_url {
174
- // Extract just the host:port from the URL
175
- let gateway_display = gateway_url
176
- .replace("http://", "")
177
- .replace("https://", "")
178
- .split('/')
179
- .next()
180
- .unwrap_or(gateway_url)
181
- .to_string();
182
-
183
- status_spans.push(Span::styled(" Gateway: ", Style::default().fg(TEXT_MUTED)));
184
- status_spans.push(Span::styled(gateway_display, Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD)));
185
-
186
- // Add health status if verified
187
- if state.gateway_healthy {
188
- status_spans.push(Span::styled(" [✓]", Style::default().fg(NEON_GREEN)));
189
- }
190
- }
191
- }
192
-
193
- let status_line = Line::from(status_spans);
194
-
195
- f.render_widget(Paragraph::new(brand_line), Rect {
196
- x: header_area.x, y: header_area.y, width: header_area.width, height: 1
197
- });
198
- f.render_widget(Paragraph::new(status_line), Rect {
199
- x: header_area.x, y: header_area.y + 1, width: header_area.width, height: 1
200
- });
201
-
202
- // Bug 6 fix: Add separator line below header (but stop before right edge)
203
- let max_sep_width = (header_area.width * 85 / 100).max(20); // Max 85% of width
204
- let separator = "-".repeat(max_sep_width as usize);
205
- f.render_widget(
206
- Paragraph::new(Line::from(Span::styled(separator, Style::default().fg(TEXT_MUTED)))),
207
- Rect {
208
- x: header_area.x,
209
- y: header_area.y + 2,
210
- width: max_sep_width,
211
- height: 1
212
- }
213
- );
214
- }
215
-
216
- fn format_uptime(secs: u64) -> String {
217
- let hours = secs / 3600;
218
- let mins = (secs % 3600) / 60;
219
- let secs = secs % 60;
220
-
221
- if hours > 0 {
222
- format!("{}h {}m {}s", hours, mins, secs)
223
- } else if mins > 0 {
224
- format!("{}m {}s", mins, secs)
225
- } else {
226
- format!("{}s", secs)
227
- }
228
- }
229
-
230
- fn render_content(f: &mut Frame, area: Rect, state: &AppState) {
231
- // Two-column layout: Left (system) + Right (logs & capabilities)
232
- let cols = Layout::default()
233
- .direction(Direction::Horizontal)
234
- .constraints([
235
- Constraint::Percentage(35), // Left panel
236
- Constraint::Percentage(65), // Right panel
237
- ])
238
- .split(area);
239
-
240
- render_left_column(f, cols[0], state);
241
-
242
- // Right column: Operations Log + Capabilities
243
- // Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
244
- let max_width = (cols[1].width * 85 / 100).max(10); // At least 10 chars, max 85% of width
245
- let right_chunks = Layout::default()
246
- .direction(Direction::Vertical)
247
- .constraints([
248
- Constraint::Percentage(70), // Operations log (main focus)
249
- Constraint::Percentage(30), // Capabilities
250
- ])
251
- .split(Rect {
252
- x: cols[1].x,
253
- y: cols[1].y,
254
- width: max_width.saturating_sub(2), // Additional 2 char margin for safety
255
- height: cols[1].height,
256
- });
257
-
258
- render_center_column(f, right_chunks[0], state);
259
-
260
- // === ADD SEPARATOR BETWEEN OPERATIONS LOG AND CAPABILITIES ===
261
- // IMPORTANT: Stop separator well before right edge (don't go too far on x-axis)
262
- if right_chunks[1].y > right_chunks[0].y + right_chunks[0].height {
263
- // Use the operations log panel width, not the full column width
264
- let ops_panel_width = right_chunks[0].width;
265
- let safe_sep_width = (ops_panel_width * 80 / 100).min(ops_panel_width.saturating_sub(15)); // Stop well before right edge
266
- render_separator(f, Rect {
267
- x: right_chunks[0].x,
268
- y: right_chunks[0].y + right_chunks[0].height,
269
- width: safe_sep_width,
270
- height: 1,
271
- });
272
- }
273
-
274
- render_right_column(f, right_chunks[1], state);
275
- }
276
-
277
- fn render_left_column(f: &mut Frame, area: Rect, state: &AppState) {
278
- let panel_area = Rect {
279
- x: area.x + 1,
280
- y: area.y,
281
- width: area.width.saturating_sub(2),
282
- height: area.height,
283
- };
284
-
285
- // Split into System Status and Resources
286
- let chunks = Layout::default()
287
- .direction(Direction::Vertical)
288
- .constraints([
289
- Constraint::Percentage(70),
290
- Constraint::Percentage(30),
291
- ])
292
- .split(panel_area);
293
-
294
- // === SYSTEM STATUS PANEL ===
295
- render_panel_border(f, chunks[0], "SYSTEM STATUS", BRAND_PURPLE);
296
-
297
- let status_content = inner_rect(chunks[0], 2);
298
- let mut lines = vec![];
299
-
300
- // Use STATIC dots for status indicators (not animated - prevents flashing when typing)
301
- // Instructions: "DON'T use animated pulse for status dots - they flash weirdly when typing!"
302
-
303
- // Posture status - Use ASCII * instead of Unicode ◉
304
- let posture_color = if state.posture_status == "Healthy" || state.connected {
305
- NEON_GREEN
306
- } else {
307
- AMBER_WARN
308
- };
309
- lines.push(Line::from(vec![
310
- Span::styled("* ", Style::default().fg(posture_color)),
311
- Span::styled("POSTURE ", Style::default().fg(TEXT_DIM)),
312
- Span::styled(&state.posture_status, Style::default().fg(posture_color).add_modifier(Modifier::BOLD)),
313
- ]));
314
-
315
- // Shield status
316
- let shield_color = match state.shield_mode.as_str() {
317
- "enforce" => NEON_GREEN,
318
- "monitor" => AMBER_WARN,
319
- _ => TEXT_MUTED,
320
- };
321
- lines.push(Line::from(vec![
322
- Span::styled("* ", Style::default().fg(shield_color)),
323
- Span::styled("SHIELD ", Style::default().fg(TEXT_DIM)),
324
- Span::styled(state.shield_mode.to_uppercase(), Style::default().fg(shield_color).add_modifier(Modifier::BOLD)),
325
- Span::styled(format!(" ({} active)", state.shield_detectors.len()), Style::default().fg(TEXT_DIM)),
326
- ]));
327
-
328
- // Sentinel status
329
- let sentinel_color = match state.sentinel_state.as_str() {
330
- "watching" => CYBER_CYAN,
331
- "triggered" => Color::Rgb(255, 69, 58),
332
- _ => TEXT_DIM,
333
- };
334
- let mut sentinel_line = vec![
335
- Span::styled("* ", Style::default().fg(sentinel_color)),
336
- Span::styled("SENTINEL ", Style::default().fg(TEXT_DIM)),
337
- Span::styled(state.sentinel_state.to_uppercase(), Style::default().fg(sentinel_color).add_modifier(Modifier::BOLD)),
338
- ];
339
- if state.sentinel_active_runs > 0 {
340
- sentinel_line.push(Span::styled(format!(" ({} runs)", state.sentinel_active_runs), Style::default().fg(TEXT_DIM)));
341
- }
342
- lines.push(Line::from(sentinel_line));
343
-
344
- // Bug 6 fix: Add visual separator between sections (but stop before right edge)
345
- // Use ASCII dashes, max 85% of width to avoid scrollbars
346
- let max_sep_width = (status_content.width * 85 / 100).max(10).min(30); // Max 30 chars or 85% of width
347
- let separator = "-".repeat(max_sep_width as usize);
348
- lines.push(Line::from(Span::styled(separator, Style::default().fg(TEXT_MUTED))));
349
-
350
- // Show enabled detectors with STATIC dots (not animated)
351
- for detector in &state.shield_detectors {
352
- let name = match detector.as_str() {
353
- "pii" => "PII Detection",
354
- "injection" => "Injection Block",
355
- "hallucination" => "Hallucination Check",
356
- _ => detector.as_str(),
357
- };
358
- lines.push(Line::from(vec![
359
- Span::styled("* ", Style::default().fg(NEON_GREEN)), // Static asterisk
360
- Span::styled(name, Style::default().fg(TEXT_PRIMARY)),
361
- ]));
362
- }
363
-
364
- // Connection status
365
- if !state.connected {
366
- lines.push(Line::from(""));
367
- lines.push(Line::from(vec![
368
- Span::styled("* ", Style::default().fg(AMBER_WARN)), // Use ASCII * instead of Unicode ⚠
369
- Span::styled("Demo Mode - Not connected", Style::default().fg(AMBER_WARN)),
370
- ]));
371
- }
372
-
373
- f.render_widget(Paragraph::new(lines), status_content);
374
-
375
- // Bug 6 fix: Add separator between System Status and Resources sections
376
- let max_sep_width = (panel_area.width * 85 / 100).max(10).min(30);
377
- let separator = "-".repeat(max_sep_width as usize);
378
- let sep_y = chunks[0].y + chunks[0].height;
379
- f.render_widget(
380
- Paragraph::new(Line::from(Span::styled(separator, Style::default().fg(TEXT_MUTED)))),
381
- Rect {
382
- x: panel_area.x,
383
- y: sep_y,
384
- width: max_sep_width,
385
- height: 1
386
- }
387
- );
388
-
389
- // === RESOURCES PANEL ===
390
- render_panel_border(f, chunks[1], "RESOURCES", TEXT_DIM);
391
- let resources_content = inner_rect(chunks[1], 2);
392
-
393
- let network_color = if state.connected { NEON_GREEN } else { TEXT_MUTED };
394
-
395
- let mut lines = vec![
396
- render_progress_bar("CPU", state.cpu, cpu_color(state.cpu)),
397
- render_progress_bar("MEM", state.mem, mem_color(state.mem)),
398
- Line::from(""),
399
- ];
400
-
401
- // Network info - Use ASCII * instead of Unicode ◉
402
- lines.push(Line::from(vec![
403
- Span::styled("NET ", Style::default().fg(TEXT_DIM)),
404
- Span::styled("* ", Style::default().fg(network_color)),
405
- Span::styled(&state.network_status, Style::default().fg(network_color)),
406
- ]));
407
- if state.connected {
408
- lines.push(Line::from(vec![
409
- Span::styled(" Runs: ", Style::default().fg(TEXT_DIM)),
410
- Span::styled(format!("{}", state.total_runs), Style::default().fg(TEXT_PRIMARY)),
411
- ]));
412
- }
413
- lines.push(Line::from(""));
414
- lines.push(Line::from(vec![
415
- Span::styled("Uptime: ", Style::default().fg(TEXT_DIM)),
416
- Span::styled(format_uptime(state.uptime_secs), Style::default().fg(TEXT_PRIMARY)),
417
- ]));
418
-
419
- f.render_widget(Paragraph::new(lines), resources_content);
420
- }
421
-
422
- fn render_panel_border(f: &mut Frame, area: Rect, title: &str, color: Color) {
423
- // Top border with title - KEEP SHORT to avoid scrollbars!
424
- // Instructions: "Only draw title, no long horizontal lines"
425
- // Use ASCII characters that work everywhere
426
- let title_line = Line::from(vec![
427
- Span::styled("[ ", Style::default().fg(TEXT_MUTED)), // ASCII [ instead of Unicode ┌
428
- Span::styled(title, Style::default().fg(color).add_modifier(Modifier::BOLD)),
429
- Span::styled(" ", Style::default()),
430
- // Only short dash, not full width - instructions say this causes scrollbars!
431
- Span::styled("-", Style::default().fg(TEXT_MUTED)), // Just a short dash, not full width
432
- ]);
433
- // Make sure width doesn't touch right edge
434
- let title_width = (title.len() as u16 + 4).min(area.width.saturating_sub(6));
435
- f.render_widget(Paragraph::new(title_line), Rect {
436
- x: area.x,
437
- y: area.y,
438
- width: title_width, // Only as wide as needed, not full width
439
- height: 1
440
- });
441
-
442
- // NO bottom border - instructions say it causes scrollbars!
443
- // The visual separation comes from spacing and color
444
- }
445
-
446
- fn inner_rect(area: Rect, padding: u16) -> Rect {
447
- Rect {
448
- x: area.x + padding,
449
- y: area.y + 1,
450
- width: area.width.saturating_sub(padding * 2 + 2),
451
- height: area.height.saturating_sub(2),
452
- }
453
- }
454
-
455
- fn render_progress_bar(label: &str, value: f64, color: Color) -> Line<'static> {
456
- let bar_width = 12;
457
- let filled = ((value * bar_width as f64) as usize).min(bar_width);
458
- let empty = bar_width - filled;
459
-
460
- Line::from(vec![
461
- Span::styled(format!("{} ", label), Style::default().fg(TEXT_DIM)),
462
- Span::styled("█".repeat(filled), Style::default().fg(color)),
463
- Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)),
464
- Span::styled(format!(" {:>3.0}%", value * 100.0), Style::default().fg(color)),
465
- ])
466
- }
467
-
468
- fn cpu_color(value: f64) -> Color {
469
- if value > 0.8 { Color::Rgb(255, 69, 58) } // Red
470
- else if value > 0.5 { Color::Rgb(255, 191, 0) } // Amber
471
- else { NEON_GREEN }
472
- }
473
-
474
- fn mem_color(value: f64) -> Color {
475
- if value > 0.8 { Color::Rgb(255, 69, 58) }
476
- else if value > 0.6 { Color::Rgb(255, 191, 0) }
477
- else { CYBER_CYAN }
478
- }
479
-
480
- fn render_center_column(f: &mut Frame, area: Rect, state: &AppState) {
481
- let panel_area = Rect {
482
- x: area.x + 1,
483
- y: area.y,
484
- width: area.width.saturating_sub(2),
485
- height: area.height,
486
- };
487
-
488
- // === OPERATIONS LOG - More contained/closed appearance ===
489
- // Draw a proper box border for a more "closed up" look
490
- let border_block = Block::default()
491
- .title(" OPERATIONS LOG ")
492
- .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
493
- .borders(Borders::ALL)
494
- .border_style(Style::default().fg(TEXT_MUTED));
495
-
496
- f.render_widget(border_block, panel_area);
497
-
498
- // Content area with padding (inside the border)
499
- let content_area = Rect {
500
- x: panel_area.x + 1,
501
- y: panel_area.y + 1,
502
- width: panel_area.width.saturating_sub(4), // Leave room for border (2) + scrollbar (2)
503
- height: panel_area.height.saturating_sub(2), // Top and bottom borders
504
- };
505
-
506
- let visible_height = content_area.height as usize;
507
- let total_logs = state.logs.len();
508
- let max_scroll = total_logs.saturating_sub(visible_height.max(1));
509
-
510
- // Calculate which logs to show (scroll_pos = 0 means newest, higher = older)
511
- let start_idx = state.log_scroll.min(max_scroll);
512
- let _end_idx = (start_idx + visible_height).min(total_logs);
513
-
514
- let spinner = SPINNERS[state.spinner_frame];
515
-
516
- let mut lines = vec![
517
- Line::from(vec![
518
- Span::styled(spinner, Style::default().fg(CYBER_CYAN)),
519
- Span::styled(" Monitoring...", Style::default().fg(TEXT_DIM).add_modifier(Modifier::ITALIC)),
520
- ]),
521
- ];
522
-
523
- // Show real logs with proper formatting (reversed order - newest first)
524
- for log in state.logs.iter().rev().skip(start_idx).take(visible_height) {
525
- if log.starts_with("[") {
526
- // Parse log format: [COMPONENT] message
527
- if let Some(bracket_end) = log.find(']') {
528
- let component = &log[1..bracket_end];
529
- let message = log[bracket_end + 1..].trim();
530
-
531
- let comp_color = match component {
532
- "GATEWAY" => CYBER_CYAN,
533
- "SHIELD" => BRAND_PURPLE,
534
- "SENTINEL" => NEON_GREEN,
535
- "WORKER" => AMBER_WARN,
536
- "SYSTEM" => TEXT_DIM,
537
- "HELP" => CYBER_CYAN,
538
- _ => TEXT_DIM,
539
- };
540
-
541
- lines.push(Line::from(vec![
542
- Span::styled("> ", Style::default().fg(comp_color)), // Use ASCII > instead of Unicode ▸
543
- Span::styled(format!("[{}] ", component), Style::default().fg(comp_color)),
544
- Span::styled(message, Style::default().fg(TEXT_PRIMARY)),
545
- ]));
546
- } else {
547
- lines.push(Line::from(Span::styled(log.as_str(), Style::default().fg(TEXT_PRIMARY))));
548
- }
549
- } else if log.starts_with(">") {
550
- // Command echo
551
- lines.push(Line::from(vec![
552
- Span::styled("> ", Style::default().fg(BRAND_PURPLE)), // Use ASCII > instead of Unicode ▸
553
- Span::styled(log, Style::default().fg(BRAND_PURPLE)),
554
- ]));
555
- } else {
556
- lines.push(Line::from(Span::styled(log.as_str(), Style::default().fg(TEXT_PRIMARY))));
557
- }
558
- }
559
-
560
- // Connection status at bottom
561
- if !state.connected {
562
- lines.push(Line::from(""));
563
- lines.push(Line::from(vec![
564
- Span::styled("* ", Style::default().fg(AMBER_WARN)), // Use ASCII * instead of Unicode ⚠
565
- Span::styled("Demo Mode - Not connected to Gateway", Style::default().fg(AMBER_WARN)),
566
- ]));
567
- }
568
-
569
- f.render_widget(Paragraph::new(lines), content_area);
570
-
571
- // === RENDER CLEAN SCROLLBAR (only if scrollable) ===
572
- // CRITICAL FIX: Only render scrollbar when actually scrollable to prevent flickering
573
- if total_logs > visible_height && max_scroll > 0 {
574
- // Scrollbar positioned inside the border, 1 char from right edge
575
- // Use fixed position to prevent jitter
576
- let scrollbar_x = panel_area.x + panel_area.width.saturating_sub(3);
577
- let scrollbar_y = content_area.y;
578
- let scrollbar_height = content_area.height;
579
-
580
- // CRITICAL: Use integer math to prevent floating point rounding issues
581
- // Calculate thumb size (minimum 1 char, proportional to visible/total)
582
- let thumb_height = ((visible_height as u32 * scrollbar_height as u32) / total_logs as u32) as u16;
583
- let thumb_height = thumb_height.max(1).min(scrollbar_height);
584
-
585
- // Calculate thumb position using integer math for stability
586
- let thumb_start = if max_scroll > 0 {
587
- // Use integer division to avoid floating point jitter
588
- ((state.log_scroll as u32 * (scrollbar_height.saturating_sub(thumb_height) as u32)) / max_scroll as u32) as u16
589
- } else {
590
- 0
591
- };
592
-
593
- // Clamp thumb_start to valid range
594
- let thumb_start = thumb_start.min(scrollbar_height.saturating_sub(thumb_height));
595
- let thumb_end = (thumb_start + thumb_height).min(scrollbar_height);
596
-
597
- // Render scrollbar track (entire height) as single operation to prevent flicker
598
- // Build scrollbar string first, then render once
599
- let mut scrollbar_chars = vec![String::from("│"); scrollbar_height as usize];
600
-
601
- // Fill thumb portion
602
- for i in thumb_start..thumb_end {
603
- if i < scrollbar_height {
604
- scrollbar_chars[i as usize] = String::from("");
605
- }
606
- }
607
-
608
- // Render entire scrollbar at once (prevents flickering from multiple renders)
609
- for (i, c) in scrollbar_chars.iter().enumerate() {
610
- let y = scrollbar_y + i as u16;
611
- if y < scrollbar_y + scrollbar_height {
612
- let color = if i >= thumb_start as usize && i < thumb_end as usize {
613
- CYBER_CYAN
614
- } else {
615
- TEXT_MUTED
616
- };
617
- f.render_widget(
618
- Paragraph::new(c.as_str()).style(Style::default().fg(color)),
619
- Rect { x: scrollbar_x, y, width: 1, height: 1 }
620
- );
621
- }
622
- }
623
- }
624
- }
625
-
626
- fn render_right_column(f: &mut Frame, area: Rect, state: &AppState) {
627
- // This now only renders CAPABILITIES (Network is shown in left panel)
628
- let panel_area = Rect {
629
- x: area.x + 1,
630
- y: area.y,
631
- width: area.width.saturating_sub(4), // Extra margin on right
632
- height: area.height,
633
- };
634
-
635
- // === CAPABILITIES PANEL ===
636
- render_panel_border(f, panel_area, "CAPABILITIES", BRAND_PURPLE);
637
- let caps_content = inner_rect(panel_area, 2);
638
-
639
- // Use STATIC indicators (not animated pulse)
640
- let mut cap_lines: Vec<Line> = vec![];
641
-
642
- if state.capabilities.is_empty() {
643
- cap_lines.push(Line::from(vec![
644
- Span::styled("- ", Style::default().fg(TEXT_MUTED)),
645
- Span::styled("No agents registered", Style::default().fg(TEXT_DIM)),
646
- ]));
647
- cap_lines.push(Line::from(vec![
648
- Span::styled(" ", Style::default()),
649
- Span::styled("Use DevKit to register agents", Style::default().fg(TEXT_MUTED)),
650
- ]));
651
- } else {
652
- for cap in state.capabilities.iter().take(caps_content.height.saturating_sub(1) as usize) {
653
- cap_lines.push(Line::from(vec![
654
- Span::styled("+ ", Style::default().fg(BRAND_PURPLE)), // Static plus sign
655
- Span::styled(cap.as_str(), Style::default().fg(TEXT_PRIMARY)),
656
- Span::styled(" ", Style::default()),
657
- Span::styled("* READY", Style::default().fg(NEON_GREEN)), // Static asterisk
658
- ]));
659
- }
660
- cap_lines.push(Line::from(vec![
661
- Span::styled(format!("{} agents", state.capabilities.len()),
662
- Style::default().fg(TEXT_DIM)),
663
- ]));
664
- }
665
-
666
- f.render_widget(Paragraph::new(cap_lines), caps_content);
667
- }
668
-
669
-
670
- fn render_command_box(f: &mut Frame, area: Rect, state: &AppState) {
671
- // Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
672
- let max_width = (area.width * 85 / 100).max(10); // At least 10 chars, max 85% of width
673
- let bar_area = Rect {
674
- x: area.x + 2,
675
- y: area.y,
676
- width: max_width.saturating_sub(2), // Additional 2 char margin
677
- height: area.height,
678
- };
679
-
680
- // NO separator line - instructions say it causes scrollbars!
681
- // Just use spacing for visual separation
682
-
683
- // Command prompt - Use ASCII > instead of Unicode ▶
684
- // Note: We show a visual cursor character "_" in the text, but the real cursor
685
- // position is handled separately below
686
- let prompt_line = Line::from(vec![
687
- Span::styled("> ", Style::default().fg(BRAND_PURPLE)), // ASCII > instead of Unicode ▶
688
- Span::styled("4runr", Style::default().fg(BRAND_VIOLET).add_modifier(Modifier::BOLD)),
689
- Span::styled(": ", Style::default().fg(TEXT_DIM)), // ASCII : instead of Unicode ›
690
- Span::styled(&state.command_input, Style::default().fg(TEXT_PRIMARY)),
691
- Span::styled("_", Style::default().fg(CYBER_CYAN)), // Visual cursor indicator
692
- ]);
693
- f.render_widget(Paragraph::new(prompt_line), Rect {
694
- x: bar_area.x, y: bar_area.y + 1, width: bar_area.width, height: 1
695
- });
696
-
697
- // Help hints - Use ASCII | instead of Unicode
698
- let help_line = Line::from(vec![
699
- Span::styled("Ctrl+C", Style::default().fg(BRAND_VIOLET)),
700
- Span::styled(" exit | ", Style::default().fg(TEXT_MUTED)), // ASCII | instead of Unicode
701
- Span::styled("F10", Style::default().fg(BRAND_VIOLET)),
702
- Span::styled(" quit | ", Style::default().fg(TEXT_MUTED)), // ASCII | instead of Unicode │
703
- Span::styled("help", Style::default().fg(BRAND_VIOLET)),
704
- Span::styled(" commands", Style::default().fg(TEXT_MUTED)),
705
- ]);
706
- f.render_widget(Paragraph::new(help_line), Rect {
707
- x: bar_area.x, y: bar_area.y + 2, width: bar_area.width, height: 1
708
- });
709
-
710
- // CRITICAL FIX: Only set cursor position when user is actively typing
711
- // This prevents cursor from appearing in wrong places (operations log, etc.)
712
- // IMPORTANT: In Ratatui, set_cursor() shows the cursor, so we only call it here
713
- if state.command_focused || !state.command_input.is_empty() {
714
- // Calculate correct cursor position: "> 4runr: " = 9 chars
715
- let prompt_len = 9u16; // "> 4runr: " = 9 characters ("> " + "4runr" + ": " = 2+5+2 = 9)
716
- let input_len = state.command_input.len() as u16;
717
- let cursor_x = bar_area.x + prompt_len + input_len;
718
- let cursor_y = bar_area.y + 1;
719
-
720
- // Ensure cursor doesn't go beyond the safe area (respects 15% margin)
721
- let max_x = (bar_area.x + bar_area.width).saturating_sub(1);
722
- let final_x = cursor_x.min(max_x);
723
-
724
- // Only set cursor if we're actually at the input field
725
- // This prevents cursor from appearing elsewhere (operations log, etc.)
726
- f.set_cursor(final_x, cursor_y);
727
- }
728
- // IMPORTANT: If not focused and no input, we don't call set_cursor()
729
- // This keeps the cursor hidden (hidden at start of main loop)
730
- }
731
-
732
- fn render_too_small(f: &mut Frame, viewport: &SafeViewport) {
733
- let msg = vec![
734
- Line::from(Span::styled("4Runr.", Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD))), // Bug 3 fix: Use "4Runr." with dot
735
- Line::from(""),
736
- Line::from(Span::styled("Terminal too small", Style::default().fg(Color::Rgb(255, 69, 58)))),
737
- Line::from(""),
738
- Line::from(Span::styled(
739
- format!("Current: {}x{}", viewport.safe_cols, viewport.safe_rows),
740
- Style::default().fg(TEXT_DIM)
741
- )),
742
- Line::from(Span::styled(
743
- format!("Required: {}x{}", MIN_COLS, MIN_ROWS),
744
- Style::default().fg(TEXT_PRIMARY)
745
- )),
746
- ];
747
-
748
- f.render_widget(
749
- Paragraph::new(msg).alignment(Alignment::Center),
750
- viewport.safe_rect
751
- );
752
- }
753
-
754
- fn render_perf_overlay(f: &mut Frame, area: Rect, state: &AppState) {
755
- // Calculate RPS from recent render durations
756
- let rps = if !state.render_durations.is_empty() {
757
- let avg_ms: f64 = state.render_durations.iter().sum::<u64>() as f64
758
- / state.render_durations.len() as f64;
759
- if avg_ms > 0.0 {
760
- (1000.0 / avg_ms) as u64
761
- } else {
762
- 0
763
- }
764
- } else {
765
- 0
766
- };
767
-
768
- let last_render_ms = if !state.render_durations.is_empty() {
769
- *state.render_durations.back().unwrap()
770
- } else {
771
- 0
772
- };
773
-
774
- let overlay_text = format!(
775
- "PERF OVERLAY (F12 to toggle)\n\
776
- RPS: {} | Last render: {}ms | Total renders: {}\n\
777
- Render scheduled: {} | Log writes: {}",
778
- rps,
779
- last_render_ms,
780
- state.render_count,
781
- state.render_scheduled_count,
782
- state.log_write_count
783
- );
784
-
785
- let overlay_area = Rect {
786
- x: area.width.saturating_sub(60),
787
- y: 2,
788
- width: 58,
789
- height: 5,
790
- };
791
-
792
- let block = Block::default()
793
- .borders(Borders::ALL)
794
- .border_style(Style::default().fg(Color::Yellow))
795
- .title(" Performance ");
796
-
797
- let paragraph = Paragraph::new(overlay_text)
798
- .block(block)
799
- .style(Style::default().fg(Color::White))
800
- .wrap(Wrap { trim: true });
801
-
802
- f.render_widget(paragraph, overlay_area);
803
- }
1
+ use crate::app::AppState;
2
+ use crate::ui::safe_viewport::SafeViewport;
3
+ use ratatui::prelude::*;
4
+ use ratatui::style::Modifier;
5
+ use ratatui::text::{Line, Span};
6
+ use ratatui::widgets::*;
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 {
38
+ x: area.x,
39
+ y: area.y,
40
+ width: safe_width,
41
+ height: 1,
42
+ },
43
+ );
44
+ }
45
+ }
46
+
47
+ pub fn render(f: &mut Frame, state: &AppState) {
48
+ // CRITICAL: Hide cursor by default at start of each render
49
+ // Only show it when explicitly set in render_command_box
50
+ // This prevents cursor from appearing in wrong places (like operations log)
51
+ let full_area = f.size();
52
+ f.render_widget(Clear, full_area);
53
+
54
+ let viewport = SafeViewport::new(full_area);
55
+
56
+ if viewport.is_too_small(MIN_COLS, MIN_ROWS) {
57
+ render_too_small(f, &viewport);
58
+ return;
59
+ }
60
+
61
+ let safe = viewport.safe_rect;
62
+
63
+ // Dark background
64
+ f.render_widget(
65
+ Block::default().style(Style::default().bg(Color::Rgb(10, 10, 15))),
66
+ full_area,
67
+ );
68
+
69
+ // Main layout: Header (3) + Content (flex) + Command (4)
70
+ let main_chunks = Layout::default()
71
+ .direction(Direction::Vertical)
72
+ .constraints([
73
+ Constraint::Length(3), // Header
74
+ Constraint::Min(15), // Content
75
+ Constraint::Length(4), // Command bar
76
+ ])
77
+ .split(safe);
78
+
79
+ render_header(f, main_chunks[0], state);
80
+
81
+ // === ADD SEPARATOR BELOW HEADER ===
82
+ if main_chunks[1].y > main_chunks[0].y + main_chunks[0].height {
83
+ render_separator(
84
+ f,
85
+ Rect {
86
+ x: safe.x,
87
+ y: main_chunks[0].y + main_chunks[0].height,
88
+ width: safe.width,
89
+ height: 1,
90
+ },
91
+ );
92
+ }
93
+
94
+ render_content(f, main_chunks[1], state);
95
+
96
+ // === ADD SEPARATOR ABOVE COMMAND BOX ===
97
+ if main_chunks[2].y > main_chunks[1].y + main_chunks[1].height {
98
+ render_separator(
99
+ f,
100
+ Rect {
101
+ x: safe.x,
102
+ y: main_chunks[1].y + main_chunks[1].height,
103
+ width: safe.width,
104
+ height: 1,
105
+ },
106
+ );
107
+ }
108
+
109
+ render_command_box(f, main_chunks[2], state);
110
+
111
+ // Perf overlay (if enabled)
112
+ if state.perf_overlay {
113
+ render_perf_overlay(f, full_area, state);
114
+ }
115
+ }
116
+
117
+ fn render_header(f: &mut Frame, area: Rect, state: &AppState) {
118
+ // Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
119
+ let max_width = (area.width * 85 / 100).max(10); // At least 10 chars, max 85% of width
120
+ let header_area = Rect {
121
+ x: area.x + 2,
122
+ y: area.y,
123
+ width: max_width.saturating_sub(2), // Additional 2 char margin
124
+ height: 3,
125
+ };
126
+
127
+ let spinner = SPINNERS[state.spinner_frame];
128
+
129
+ // Format uptime
130
+ let uptime_str = format_uptime(state.uptime_secs);
131
+
132
+ // Operation mode indicator (Step 7)
133
+ let mode_text = state.operation_mode.as_str();
134
+ let mode_color = match state.operation_mode {
135
+ crate::app::OperationMode::Local => AMBER_WARN,
136
+ crate::app::OperationMode::Connected => NEON_GREEN,
137
+ };
138
+
139
+ // Connection status icon - Phase 1.1: Connection Visibility
140
+ let connection_icon = if state.gateway_healthy && state.connected {
141
+ "[●]" // Green dot - verified & healthy
142
+ } else if state.connected {
143
+ "[○]" // Yellow - connected but not verified
144
+ } else {
145
+ "[ ]" // Gray - not connected
146
+ };
147
+
148
+ let connection_color = if state.gateway_healthy && state.connected {
149
+ NEON_GREEN
150
+ } else if state.connected {
151
+ AMBER_WARN
152
+ } else {
153
+ TEXT_MUTED
154
+ };
155
+
156
+ // Line 1: Brand + version + mode + uptime - Bug 3 fix: Use "4Runr." with dot (matches brand logo)
157
+ // Version from env 4RUNR_CLI_VERSION (set by Node CLI at launch); fallback for dev without CLI
158
+ let ver_env = std::env::var("4RUNR_CLI_VERSION").unwrap_or_else(|_| "2.9.69".to_string());
159
+ let package_version = ver_env.trim();
160
+ let package_version: &str = if package_version.is_empty() {
161
+ "2.9.69"
162
+ } else {
163
+ package_version
164
+ };
165
+ let brand_line = Line::from(vec![
166
+ Span::styled(
167
+ "4Runr.",
168
+ Style::default()
169
+ .fg(BRAND_PURPLE)
170
+ .add_modifier(Modifier::BOLD),
171
+ ),
172
+ Span::styled(" AI AGENT OS", Style::default().fg(BRAND_VIOLET)),
173
+ Span::styled(
174
+ format!(" v{}", package_version),
175
+ Style::default().fg(TEXT_MUTED),
176
+ ),
177
+ Span::styled(" Mode: ", Style::default().fg(TEXT_MUTED)),
178
+ Span::styled(
179
+ mode_text,
180
+ Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
181
+ ),
182
+ Span::styled(" ", Style::default()),
183
+ Span::styled(
184
+ connection_icon,
185
+ Style::default()
186
+ .fg(connection_color)
187
+ .add_modifier(Modifier::BOLD),
188
+ ),
189
+ Span::styled(
190
+ format!(" {} UPTIME: {}", spinner, uptime_str),
191
+ Style::default().fg(TEXT_MUTED),
192
+ ),
193
+ ]);
194
+
195
+ // Line 2: Status bar with Gateway URL - Phase 1.1: Connection Visibility
196
+ // NO long lines that touch right edge (causes scrollbars!)
197
+ let mut status_spans = vec![
198
+ Span::styled(" ", Style::default()), // Indent to align
199
+ Span::styled("*", Style::default().fg(NEON_GREEN)),
200
+ Span::styled(
201
+ " SYSTEM ONLINE ",
202
+ Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD),
203
+ ),
204
+ Span::styled("*", Style::default().fg(NEON_GREEN)),
205
+ ];
206
+
207
+ // Add Gateway info if connected
208
+ if state.connected {
209
+ if let Some(gateway_url) = &state.gateway_url {
210
+ // Extract just the host:port from the URL
211
+ let gateway_display = gateway_url
212
+ .replace("http://", "")
213
+ .replace("https://", "")
214
+ .split('/')
215
+ .next()
216
+ .unwrap_or(gateway_url)
217
+ .to_string();
218
+
219
+ status_spans.push(Span::styled(
220
+ " Gateway: ",
221
+ Style::default().fg(TEXT_MUTED),
222
+ ));
223
+ status_spans.push(Span::styled(
224
+ gateway_display,
225
+ Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD),
226
+ ));
227
+
228
+ // Add health status if verified
229
+ if state.gateway_healthy {
230
+ status_spans.push(Span::styled(" [✓]", Style::default().fg(NEON_GREEN)));
231
+ }
232
+ }
233
+ }
234
+
235
+ let status_line = Line::from(status_spans);
236
+
237
+ f.render_widget(
238
+ Paragraph::new(brand_line),
239
+ Rect {
240
+ x: header_area.x,
241
+ y: header_area.y,
242
+ width: header_area.width,
243
+ height: 1,
244
+ },
245
+ );
246
+ f.render_widget(
247
+ Paragraph::new(status_line),
248
+ Rect {
249
+ x: header_area.x,
250
+ y: header_area.y + 1,
251
+ width: header_area.width,
252
+ height: 1,
253
+ },
254
+ );
255
+
256
+ // Bug 6 fix: Add separator line below header (but stop before right edge)
257
+ let max_sep_width = (header_area.width * 85 / 100).max(20); // Max 85% of width
258
+ let separator = "-".repeat(max_sep_width as usize);
259
+ f.render_widget(
260
+ Paragraph::new(Line::from(Span::styled(
261
+ separator,
262
+ Style::default().fg(TEXT_MUTED),
263
+ ))),
264
+ Rect {
265
+ x: header_area.x,
266
+ y: header_area.y + 2,
267
+ width: max_sep_width,
268
+ height: 1,
269
+ },
270
+ );
271
+ }
272
+
273
+ fn format_uptime(secs: u64) -> String {
274
+ let hours = secs / 3600;
275
+ let mins = (secs % 3600) / 60;
276
+ let secs = secs % 60;
277
+
278
+ if hours > 0 {
279
+ format!("{}h {}m {}s", hours, mins, secs)
280
+ } else if mins > 0 {
281
+ format!("{}m {}s", mins, secs)
282
+ } else {
283
+ format!("{}s", secs)
284
+ }
285
+ }
286
+
287
+ fn render_content(f: &mut Frame, area: Rect, state: &AppState) {
288
+ // Two-column layout: Left (system) + Right (logs & capabilities)
289
+ let cols = Layout::default()
290
+ .direction(Direction::Horizontal)
291
+ .constraints([
292
+ Constraint::Percentage(35), // Left panel
293
+ Constraint::Percentage(65), // Right panel
294
+ ])
295
+ .split(area);
296
+
297
+ render_left_column(f, cols[0], state);
298
+
299
+ // Right column: Operations Log + Capabilities
300
+ // Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
301
+ let max_width = (cols[1].width * 85 / 100).max(10); // At least 10 chars, max 85% of width
302
+ let right_chunks = Layout::default()
303
+ .direction(Direction::Vertical)
304
+ .constraints([
305
+ Constraint::Percentage(70), // Operations log (main focus)
306
+ Constraint::Percentage(30), // Capabilities
307
+ ])
308
+ .split(Rect {
309
+ x: cols[1].x,
310
+ y: cols[1].y,
311
+ width: max_width.saturating_sub(2), // Additional 2 char margin for safety
312
+ height: cols[1].height,
313
+ });
314
+
315
+ render_center_column(f, right_chunks[0], state);
316
+
317
+ // === ADD SEPARATOR BETWEEN OPERATIONS LOG AND CAPABILITIES ===
318
+ // IMPORTANT: Stop separator well before right edge (don't go too far on x-axis)
319
+ if right_chunks[1].y > right_chunks[0].y + right_chunks[0].height {
320
+ // Use the operations log panel width, not the full column width
321
+ let ops_panel_width = right_chunks[0].width;
322
+ let safe_sep_width = (ops_panel_width * 80 / 100).min(ops_panel_width.saturating_sub(15)); // Stop well before right edge
323
+ render_separator(
324
+ f,
325
+ Rect {
326
+ x: right_chunks[0].x,
327
+ y: right_chunks[0].y + right_chunks[0].height,
328
+ width: safe_sep_width,
329
+ height: 1,
330
+ },
331
+ );
332
+ }
333
+
334
+ render_right_column(f, right_chunks[1], state);
335
+ }
336
+
337
+ fn render_left_column(f: &mut Frame, area: Rect, state: &AppState) {
338
+ let panel_area = Rect {
339
+ x: area.x + 1,
340
+ y: area.y,
341
+ width: area.width.saturating_sub(2),
342
+ height: area.height,
343
+ };
344
+
345
+ // Split into System Status and Resources
346
+ let chunks = Layout::default()
347
+ .direction(Direction::Vertical)
348
+ .constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
349
+ .split(panel_area);
350
+
351
+ // === SYSTEM STATUS PANEL ===
352
+ render_panel_border(f, chunks[0], "SYSTEM STATUS", BRAND_PURPLE);
353
+
354
+ let status_content = inner_rect(chunks[0], 2);
355
+ let mut lines = vec![];
356
+
357
+ // Use STATIC dots for status indicators (not animated - prevents flashing when typing)
358
+ // Instructions: "DON'T use animated pulse for status dots - they flash weirdly when typing!"
359
+
360
+ // Posture status - Use ASCII * instead of Unicode ◉
361
+ let posture_color = if state.posture_status == "Healthy" || state.connected {
362
+ NEON_GREEN
363
+ } else {
364
+ AMBER_WARN
365
+ };
366
+ lines.push(Line::from(vec![
367
+ Span::styled("* ", Style::default().fg(posture_color)),
368
+ Span::styled("POSTURE ", Style::default().fg(TEXT_DIM)),
369
+ Span::styled(
370
+ &state.posture_status,
371
+ Style::default()
372
+ .fg(posture_color)
373
+ .add_modifier(Modifier::BOLD),
374
+ ),
375
+ ]));
376
+
377
+ // Shield status
378
+ let shield_color = match state.shield_mode.as_str() {
379
+ "enforce" => NEON_GREEN,
380
+ "monitor" => AMBER_WARN,
381
+ _ => TEXT_MUTED,
382
+ };
383
+ lines.push(Line::from(vec![
384
+ Span::styled("* ", Style::default().fg(shield_color)),
385
+ Span::styled("SHIELD ", Style::default().fg(TEXT_DIM)),
386
+ Span::styled(
387
+ state.shield_mode.to_uppercase(),
388
+ Style::default()
389
+ .fg(shield_color)
390
+ .add_modifier(Modifier::BOLD),
391
+ ),
392
+ Span::styled(
393
+ format!(" ({} active)", state.shield_detectors.len()),
394
+ Style::default().fg(TEXT_DIM),
395
+ ),
396
+ ]));
397
+
398
+ // Sentinel status
399
+ let sentinel_color = match state.sentinel_state.as_str() {
400
+ "watching" => CYBER_CYAN,
401
+ "triggered" => Color::Rgb(255, 69, 58),
402
+ _ => TEXT_DIM,
403
+ };
404
+ let mut sentinel_line = vec![
405
+ Span::styled("* ", Style::default().fg(sentinel_color)),
406
+ Span::styled("SENTINEL ", Style::default().fg(TEXT_DIM)),
407
+ Span::styled(
408
+ state.sentinel_state.to_uppercase(),
409
+ Style::default()
410
+ .fg(sentinel_color)
411
+ .add_modifier(Modifier::BOLD),
412
+ ),
413
+ ];
414
+ if state.sentinel_active_runs > 0 {
415
+ sentinel_line.push(Span::styled(
416
+ format!(" ({} runs)", state.sentinel_active_runs),
417
+ Style::default().fg(TEXT_DIM),
418
+ ));
419
+ }
420
+ lines.push(Line::from(sentinel_line));
421
+
422
+ // Bug 6 fix: Add visual separator between sections (but stop before right edge)
423
+ // Use ASCII dashes, max 85% of width to avoid scrollbars
424
+ let max_sep_width = (status_content.width * 85 / 100).max(10).min(30); // Max 30 chars or 85% of width
425
+ let separator = "-".repeat(max_sep_width as usize);
426
+ lines.push(Line::from(Span::styled(
427
+ separator,
428
+ Style::default().fg(TEXT_MUTED),
429
+ )));
430
+
431
+ // Show enabled detectors with STATIC dots (not animated)
432
+ for detector in &state.shield_detectors {
433
+ let name = match detector.as_str() {
434
+ "pii" => "PII Detection",
435
+ "injection" => "Injection Block",
436
+ "hallucination" => "Hallucination Check",
437
+ _ => detector.as_str(),
438
+ };
439
+ lines.push(Line::from(vec![
440
+ Span::styled("* ", Style::default().fg(NEON_GREEN)), // Static asterisk
441
+ Span::styled(name, Style::default().fg(TEXT_PRIMARY)),
442
+ ]));
443
+ }
444
+
445
+ // Connection status
446
+ if !state.connected {
447
+ lines.push(Line::from(""));
448
+ lines.push(Line::from(vec![
449
+ Span::styled("* ", Style::default().fg(AMBER_WARN)), // Use ASCII * instead of Unicode ⚠
450
+ Span::styled("Demo Mode - Not connected", Style::default().fg(AMBER_WARN)),
451
+ ]));
452
+ }
453
+
454
+ f.render_widget(Paragraph::new(lines), status_content);
455
+
456
+ // Bug 6 fix: Add separator between System Status and Resources sections
457
+ let max_sep_width = (panel_area.width * 85 / 100).max(10).min(30);
458
+ let separator = "-".repeat(max_sep_width as usize);
459
+ let sep_y = chunks[0].y + chunks[0].height;
460
+ f.render_widget(
461
+ Paragraph::new(Line::from(Span::styled(
462
+ separator,
463
+ Style::default().fg(TEXT_MUTED),
464
+ ))),
465
+ Rect {
466
+ x: panel_area.x,
467
+ y: sep_y,
468
+ width: max_sep_width,
469
+ height: 1,
470
+ },
471
+ );
472
+
473
+ // === RESOURCES PANEL ===
474
+ render_panel_border(f, chunks[1], "RESOURCES", TEXT_DIM);
475
+ let resources_content = inner_rect(chunks[1], 2);
476
+
477
+ let network_color = if state.connected {
478
+ NEON_GREEN
479
+ } else {
480
+ TEXT_MUTED
481
+ };
482
+
483
+ let mut lines = vec![
484
+ render_progress_bar("CPU", state.cpu, cpu_color(state.cpu)),
485
+ render_progress_bar("MEM", state.mem, mem_color(state.mem)),
486
+ Line::from(""),
487
+ ];
488
+
489
+ // Network info - Use ASCII * instead of Unicode
490
+ lines.push(Line::from(vec![
491
+ Span::styled("NET ", Style::default().fg(TEXT_DIM)),
492
+ Span::styled("* ", Style::default().fg(network_color)),
493
+ Span::styled(&state.network_status, Style::default().fg(network_color)),
494
+ ]));
495
+ if state.connected {
496
+ lines.push(Line::from(vec![
497
+ Span::styled(" Runs: ", Style::default().fg(TEXT_DIM)),
498
+ Span::styled(
499
+ format!("{}", state.total_runs),
500
+ Style::default().fg(TEXT_PRIMARY),
501
+ ),
502
+ ]));
503
+ }
504
+ lines.push(Line::from(""));
505
+ lines.push(Line::from(vec![
506
+ Span::styled("Uptime: ", Style::default().fg(TEXT_DIM)),
507
+ Span::styled(
508
+ format_uptime(state.uptime_secs),
509
+ Style::default().fg(TEXT_PRIMARY),
510
+ ),
511
+ ]));
512
+
513
+ f.render_widget(Paragraph::new(lines), resources_content);
514
+ }
515
+
516
+ fn render_panel_border(f: &mut Frame, area: Rect, title: &str, color: Color) {
517
+ // Top border with title - KEEP SHORT to avoid scrollbars!
518
+ // Instructions: "Only draw title, no long horizontal lines"
519
+ // Use ASCII characters that work everywhere
520
+ let title_line = Line::from(vec![
521
+ Span::styled("[ ", Style::default().fg(TEXT_MUTED)), // ASCII [ instead of Unicode ┌
522
+ Span::styled(
523
+ title,
524
+ Style::default().fg(color).add_modifier(Modifier::BOLD),
525
+ ),
526
+ Span::styled(" ", Style::default()),
527
+ // Only short dash, not full width - instructions say this causes scrollbars!
528
+ Span::styled("-", Style::default().fg(TEXT_MUTED)), // Just a short dash, not full width
529
+ ]);
530
+ // Make sure width doesn't touch right edge
531
+ let title_width = (title.len() as u16 + 4).min(area.width.saturating_sub(6));
532
+ f.render_widget(
533
+ Paragraph::new(title_line),
534
+ Rect {
535
+ x: area.x,
536
+ y: area.y,
537
+ width: title_width, // Only as wide as needed, not full width
538
+ height: 1,
539
+ },
540
+ );
541
+
542
+ // NO bottom border - instructions say it causes scrollbars!
543
+ // The visual separation comes from spacing and color
544
+ }
545
+
546
+ fn inner_rect(area: Rect, padding: u16) -> Rect {
547
+ Rect {
548
+ x: area.x + padding,
549
+ y: area.y + 1,
550
+ width: area.width.saturating_sub(padding * 2 + 2),
551
+ height: area.height.saturating_sub(2),
552
+ }
553
+ }
554
+
555
+ fn render_progress_bar(label: &str, value: f64, color: Color) -> Line<'static> {
556
+ let bar_width = 12;
557
+ let filled = ((value * bar_width as f64) as usize).min(bar_width);
558
+ let empty = bar_width - filled;
559
+
560
+ Line::from(vec![
561
+ Span::styled(format!("{} ", label), Style::default().fg(TEXT_DIM)),
562
+ Span::styled("".repeat(filled), Style::default().fg(color)),
563
+ Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)),
564
+ Span::styled(
565
+ format!(" {:>3.0}%", value * 100.0),
566
+ Style::default().fg(color),
567
+ ),
568
+ ])
569
+ }
570
+
571
+ fn cpu_color(value: f64) -> Color {
572
+ if value > 0.8 {
573
+ Color::Rgb(255, 69, 58)
574
+ }
575
+ // Red
576
+ else if value > 0.5 {
577
+ Color::Rgb(255, 191, 0)
578
+ }
579
+ // Amber
580
+ else {
581
+ NEON_GREEN
582
+ }
583
+ }
584
+
585
+ fn mem_color(value: f64) -> Color {
586
+ if value > 0.8 {
587
+ Color::Rgb(255, 69, 58)
588
+ } else if value > 0.6 {
589
+ Color::Rgb(255, 191, 0)
590
+ } else {
591
+ CYBER_CYAN
592
+ }
593
+ }
594
+
595
+ fn render_center_column(f: &mut Frame, area: Rect, state: &AppState) {
596
+ let panel_area = Rect {
597
+ x: area.x + 1,
598
+ y: area.y,
599
+ width: area.width.saturating_sub(2),
600
+ height: area.height,
601
+ };
602
+
603
+ // === OPERATIONS LOG - More contained/closed appearance ===
604
+ // Draw a proper box border for a more "closed up" look
605
+ let border_block = Block::default()
606
+ .title(" OPERATIONS LOG ")
607
+ .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
608
+ .borders(Borders::ALL)
609
+ .border_style(Style::default().fg(TEXT_MUTED));
610
+
611
+ f.render_widget(border_block, panel_area);
612
+
613
+ // Content area with padding (inside the border)
614
+ let content_area = Rect {
615
+ x: panel_area.x + 1,
616
+ y: panel_area.y + 1,
617
+ width: panel_area.width.saturating_sub(4), // Leave room for border (2) + scrollbar (2)
618
+ height: panel_area.height.saturating_sub(2), // Top and bottom borders
619
+ };
620
+
621
+ let visible_height = content_area.height as usize;
622
+ let total_logs = state.logs.len();
623
+ let max_scroll = total_logs.saturating_sub(visible_height.max(1));
624
+
625
+ // Calculate which logs to show (scroll_pos = 0 means newest, higher = older)
626
+ let start_idx = state.log_scroll.min(max_scroll);
627
+ let _end_idx = (start_idx + visible_height).min(total_logs);
628
+
629
+ let spinner = SPINNERS[state.spinner_frame];
630
+
631
+ let mut lines = vec![Line::from(vec![
632
+ Span::styled(spinner, Style::default().fg(CYBER_CYAN)),
633
+ Span::styled(
634
+ " Monitoring...",
635
+ Style::default().fg(TEXT_DIM).add_modifier(Modifier::ITALIC),
636
+ ),
637
+ ])];
638
+
639
+ // Show real logs with proper formatting (reversed order - newest first)
640
+ for log in state.logs.iter().rev().skip(start_idx).take(visible_height) {
641
+ if log.starts_with("[") {
642
+ // Parse log format: [COMPONENT] message
643
+ if let Some(bracket_end) = log.find(']') {
644
+ let component = &log[1..bracket_end];
645
+ let message = log[bracket_end + 1..].trim();
646
+
647
+ let comp_color = match component {
648
+ "GATEWAY" => CYBER_CYAN,
649
+ "SHIELD" => BRAND_PURPLE,
650
+ "SENTINEL" => NEON_GREEN,
651
+ "WORKER" => AMBER_WARN,
652
+ "SYSTEM" => TEXT_DIM,
653
+ "HELP" => CYBER_CYAN,
654
+ _ => TEXT_DIM,
655
+ };
656
+
657
+ lines.push(Line::from(vec![
658
+ Span::styled("> ", Style::default().fg(comp_color)), // Use ASCII > instead of Unicode ▸
659
+ Span::styled(format!("[{}] ", component), Style::default().fg(comp_color)),
660
+ Span::styled(message, Style::default().fg(TEXT_PRIMARY)),
661
+ ]));
662
+ } else {
663
+ lines.push(Line::from(Span::styled(
664
+ log.as_str(),
665
+ Style::default().fg(TEXT_PRIMARY),
666
+ )));
667
+ }
668
+ } else if log.starts_with(">") {
669
+ // Command echo
670
+ lines.push(Line::from(vec![
671
+ Span::styled("> ", Style::default().fg(BRAND_PURPLE)), // Use ASCII > instead of Unicode
672
+ Span::styled(log, Style::default().fg(BRAND_PURPLE)),
673
+ ]));
674
+ } else {
675
+ lines.push(Line::from(Span::styled(
676
+ log.as_str(),
677
+ Style::default().fg(TEXT_PRIMARY),
678
+ )));
679
+ }
680
+ }
681
+
682
+ // Connection status at bottom
683
+ if !state.connected {
684
+ lines.push(Line::from(""));
685
+ lines.push(Line::from(vec![
686
+ Span::styled("* ", Style::default().fg(AMBER_WARN)), // Use ASCII * instead of Unicode ⚠
687
+ Span::styled(
688
+ "Demo Mode - Not connected to Gateway",
689
+ Style::default().fg(AMBER_WARN),
690
+ ),
691
+ ]));
692
+ }
693
+
694
+ f.render_widget(Paragraph::new(lines), content_area);
695
+
696
+ // === RENDER CLEAN SCROLLBAR (only if scrollable) ===
697
+ // CRITICAL FIX: Only render scrollbar when actually scrollable to prevent flickering
698
+ if total_logs > visible_height && max_scroll > 0 {
699
+ // Scrollbar positioned inside the border, 1 char from right edge
700
+ // Use fixed position to prevent jitter
701
+ let scrollbar_x = panel_area.x + panel_area.width.saturating_sub(3);
702
+ let scrollbar_y = content_area.y;
703
+ let scrollbar_height = content_area.height;
704
+
705
+ // CRITICAL: Use integer math to prevent floating point rounding issues
706
+ // Calculate thumb size (minimum 1 char, proportional to visible/total)
707
+ let thumb_height =
708
+ ((visible_height as u32 * scrollbar_height as u32) / total_logs as u32) as u16;
709
+ let thumb_height = thumb_height.max(1).min(scrollbar_height);
710
+
711
+ // Calculate thumb position using integer math for stability
712
+ let thumb_start = if max_scroll > 0 {
713
+ // Use integer division to avoid floating point jitter
714
+ ((state.log_scroll as u32 * (scrollbar_height.saturating_sub(thumb_height) as u32))
715
+ / max_scroll as u32) as u16
716
+ } else {
717
+ 0
718
+ };
719
+
720
+ // Clamp thumb_start to valid range
721
+ let thumb_start = thumb_start.min(scrollbar_height.saturating_sub(thumb_height));
722
+ let thumb_end = (thumb_start + thumb_height).min(scrollbar_height);
723
+
724
+ // Render scrollbar track (entire height) as single operation to prevent flicker
725
+ // Build scrollbar string first, then render once
726
+ let mut scrollbar_chars = vec![String::from("│"); scrollbar_height as usize];
727
+
728
+ // Fill thumb portion
729
+ for i in thumb_start..thumb_end {
730
+ if i < scrollbar_height {
731
+ scrollbar_chars[i as usize] = String::from("█");
732
+ }
733
+ }
734
+
735
+ // Render entire scrollbar at once (prevents flickering from multiple renders)
736
+ for (i, c) in scrollbar_chars.iter().enumerate() {
737
+ let y = scrollbar_y + i as u16;
738
+ if y < scrollbar_y + scrollbar_height {
739
+ let color = if i >= thumb_start as usize && i < thumb_end as usize {
740
+ CYBER_CYAN
741
+ } else {
742
+ TEXT_MUTED
743
+ };
744
+ f.render_widget(
745
+ Paragraph::new(c.as_str()).style(Style::default().fg(color)),
746
+ Rect {
747
+ x: scrollbar_x,
748
+ y,
749
+ width: 1,
750
+ height: 1,
751
+ },
752
+ );
753
+ }
754
+ }
755
+ }
756
+ }
757
+
758
+ fn render_right_column(f: &mut Frame, area: Rect, state: &AppState) {
759
+ // This now only renders CAPABILITIES (Network is shown in left panel)
760
+ let panel_area = Rect {
761
+ x: area.x + 1,
762
+ y: area.y,
763
+ width: area.width.saturating_sub(4), // Extra margin on right
764
+ height: area.height,
765
+ };
766
+
767
+ // === CAPABILITIES PANEL ===
768
+ render_panel_border(f, panel_area, "CAPABILITIES", BRAND_PURPLE);
769
+ let caps_content = inner_rect(panel_area, 2);
770
+
771
+ // Use STATIC indicators (not animated pulse)
772
+ let mut cap_lines: Vec<Line> = vec![];
773
+
774
+ if state.capabilities.is_empty() {
775
+ cap_lines.push(Line::from(vec![
776
+ Span::styled("- ", Style::default().fg(TEXT_MUTED)),
777
+ Span::styled("No agents registered", Style::default().fg(TEXT_DIM)),
778
+ ]));
779
+ cap_lines.push(Line::from(vec![
780
+ Span::styled(" ", Style::default()),
781
+ Span::styled(
782
+ "Use DevKit to register agents",
783
+ Style::default().fg(TEXT_MUTED),
784
+ ),
785
+ ]));
786
+ } else {
787
+ for cap in state
788
+ .capabilities
789
+ .iter()
790
+ .take(caps_content.height.saturating_sub(1) as usize)
791
+ {
792
+ cap_lines.push(Line::from(vec![
793
+ Span::styled("+ ", Style::default().fg(BRAND_PURPLE)), // Static plus sign
794
+ Span::styled(cap.as_str(), Style::default().fg(TEXT_PRIMARY)),
795
+ Span::styled(" ", Style::default()),
796
+ Span::styled("* READY", Style::default().fg(NEON_GREEN)), // Static asterisk
797
+ ]));
798
+ }
799
+ cap_lines.push(Line::from(vec![Span::styled(
800
+ format!("{} agents", state.capabilities.len()),
801
+ Style::default().fg(TEXT_DIM),
802
+ )]));
803
+ }
804
+
805
+ f.render_widget(Paragraph::new(cap_lines), caps_content);
806
+ }
807
+
808
+ fn render_command_box(f: &mut Frame, area: Rect, state: &AppState) {
809
+ // Bug 2 fix: Ensure 15% margin from right edge (use 85% of width max)
810
+ let max_width = (area.width * 85 / 100).max(10); // At least 10 chars, max 85% of width
811
+ let bar_area = Rect {
812
+ x: area.x + 2,
813
+ y: area.y,
814
+ width: max_width.saturating_sub(2), // Additional 2 char margin
815
+ height: area.height,
816
+ };
817
+
818
+ // NO separator line - instructions say it causes scrollbars!
819
+ // Just use spacing for visual separation
820
+
821
+ // Command prompt - Use ASCII > instead of Unicode ▶
822
+ // Note: We show a visual cursor character "_" in the text, but the real cursor
823
+ // position is handled separately below
824
+ let prompt_line = Line::from(vec![
825
+ Span::styled("> ", Style::default().fg(BRAND_PURPLE)), // ASCII > instead of Unicode ▶
826
+ Span::styled(
827
+ "4runr",
828
+ Style::default()
829
+ .fg(BRAND_VIOLET)
830
+ .add_modifier(Modifier::BOLD),
831
+ ),
832
+ Span::styled(": ", Style::default().fg(TEXT_DIM)), // ASCII : instead of Unicode ›
833
+ Span::styled(&state.command_input, Style::default().fg(TEXT_PRIMARY)),
834
+ Span::styled("_", Style::default().fg(CYBER_CYAN)), // Visual cursor indicator
835
+ ]);
836
+ f.render_widget(
837
+ Paragraph::new(prompt_line),
838
+ Rect {
839
+ x: bar_area.x,
840
+ y: bar_area.y + 1,
841
+ width: bar_area.width,
842
+ height: 1,
843
+ },
844
+ );
845
+
846
+ // Help hints - Use ASCII | instead of Unicode │
847
+ let help_line = Line::from(vec![
848
+ Span::styled("Ctrl+C", Style::default().fg(BRAND_VIOLET)),
849
+ Span::styled(" exit | ", Style::default().fg(TEXT_MUTED)), // ASCII | instead of Unicode │
850
+ Span::styled("F10", Style::default().fg(BRAND_VIOLET)),
851
+ Span::styled(" quit | ", Style::default().fg(TEXT_MUTED)), // ASCII | instead of Unicode │
852
+ Span::styled("help", Style::default().fg(BRAND_VIOLET)),
853
+ Span::styled(" commands", Style::default().fg(TEXT_MUTED)),
854
+ ]);
855
+ f.render_widget(
856
+ Paragraph::new(help_line),
857
+ Rect {
858
+ x: bar_area.x,
859
+ y: bar_area.y + 2,
860
+ width: bar_area.width,
861
+ height: 1,
862
+ },
863
+ );
864
+
865
+ // CRITICAL FIX: Only set cursor position when user is actively typing
866
+ // This prevents cursor from appearing in wrong places (operations log, etc.)
867
+ // IMPORTANT: In Ratatui, set_cursor() shows the cursor, so we only call it here
868
+ if state.command_focused || !state.command_input.is_empty() {
869
+ // Calculate correct cursor position: "> 4runr: " = 9 chars
870
+ let prompt_len = 9u16; // "> 4runr: " = 9 characters ("> " + "4runr" + ": " = 2+5+2 = 9)
871
+ let input_len = state.command_input.len() as u16;
872
+ let cursor_x = bar_area.x + prompt_len + input_len;
873
+ let cursor_y = bar_area.y + 1;
874
+
875
+ // Ensure cursor doesn't go beyond the safe area (respects 15% margin)
876
+ let max_x = (bar_area.x + bar_area.width).saturating_sub(1);
877
+ let final_x = cursor_x.min(max_x);
878
+
879
+ // Only set cursor if we're actually at the input field
880
+ // This prevents cursor from appearing elsewhere (operations log, etc.)
881
+ f.set_cursor(final_x, cursor_y);
882
+ }
883
+ // IMPORTANT: If not focused and no input, we don't call set_cursor()
884
+ // This keeps the cursor hidden (hidden at start of main loop)
885
+ }
886
+
887
+ fn render_too_small(f: &mut Frame, viewport: &SafeViewport) {
888
+ let msg = vec![
889
+ Line::from(Span::styled(
890
+ "4Runr.",
891
+ Style::default()
892
+ .fg(BRAND_PURPLE)
893
+ .add_modifier(Modifier::BOLD),
894
+ )), // Bug 3 fix: Use "4Runr." with dot
895
+ Line::from(""),
896
+ Line::from(Span::styled(
897
+ "Terminal too small",
898
+ Style::default().fg(Color::Rgb(255, 69, 58)),
899
+ )),
900
+ Line::from(""),
901
+ Line::from(Span::styled(
902
+ format!("Current: {}x{}", viewport.safe_cols, viewport.safe_rows),
903
+ Style::default().fg(TEXT_DIM),
904
+ )),
905
+ Line::from(Span::styled(
906
+ format!("Required: {}x{}", MIN_COLS, MIN_ROWS),
907
+ Style::default().fg(TEXT_PRIMARY),
908
+ )),
909
+ ];
910
+
911
+ f.render_widget(
912
+ Paragraph::new(msg).alignment(Alignment::Center),
913
+ viewport.safe_rect,
914
+ );
915
+ }
916
+
917
+ fn render_perf_overlay(f: &mut Frame, area: Rect, state: &AppState) {
918
+ // Calculate RPS from recent render durations
919
+ let rps = if !state.render_durations.is_empty() {
920
+ let avg_ms: f64 =
921
+ state.render_durations.iter().sum::<u64>() as f64 / state.render_durations.len() as f64;
922
+ if avg_ms > 0.0 {
923
+ (1000.0 / avg_ms) as u64
924
+ } else {
925
+ 0
926
+ }
927
+ } else {
928
+ 0
929
+ };
930
+
931
+ let last_render_ms = if !state.render_durations.is_empty() {
932
+ *state.render_durations.back().unwrap()
933
+ } else {
934
+ 0
935
+ };
936
+
937
+ let overlay_text = format!(
938
+ "PERF OVERLAY (F12 to toggle)\n\
939
+ RPS: {} | Last render: {}ms | Total renders: {}\n\
940
+ Render scheduled: {} | Log writes: {}",
941
+ rps,
942
+ last_render_ms,
943
+ state.render_count,
944
+ state.render_scheduled_count,
945
+ state.log_write_count
946
+ );
947
+
948
+ let overlay_area = Rect {
949
+ x: area.width.saturating_sub(60),
950
+ y: 2,
951
+ width: 58,
952
+ height: 5,
953
+ };
954
+
955
+ let block = Block::default()
956
+ .borders(Borders::ALL)
957
+ .border_style(Style::default().fg(Color::Yellow))
958
+ .title(" Performance ");
959
+
960
+ let paragraph = Paragraph::new(overlay_text)
961
+ .block(block)
962
+ .style(Style::default().fg(Color::White))
963
+ .wrap(Wrap { trim: true });
964
+
965
+ f.render_widget(paragraph, overlay_area);
966
+ }