4runr-os-mk3 0.1.1 → 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.
@@ -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
+ }