4runr-os 2.3.8 → 2.4.0

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.
@@ -10,11 +10,130 @@ const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
10
10
  const BRAND_VIOLET: Color = Color::Rgb(148, 103, 189);
11
11
  const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
12
12
  const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
13
+ const AMBER_WARN: Color = Color::Rgb(255, 191, 0);
13
14
  const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
14
15
  const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
15
16
  const TEXT_MUTED: Color = Color::Rgb(60, 60, 80);
16
17
  const BG_PANEL: Color = Color::Rgb(18, 18, 25);
17
18
 
19
+ // === MODEL INFORMATION ===
20
+ struct ModelInfo {
21
+ context_window: u32,
22
+ max_output: u32,
23
+ training_cutoff: &'static str,
24
+ capabilities: Vec<&'static str>,
25
+ }
26
+
27
+ fn get_model_info(provider: &str, model: &str) -> ModelInfo {
28
+ match (provider, model) {
29
+ // OpenAI
30
+ ("openai", "gpt-4") => ModelInfo {
31
+ context_window: 8192,
32
+ max_output: 4096,
33
+ training_cutoff: "Sep 2021",
34
+ capabilities: vec!["Text", "Code", "Analysis"],
35
+ },
36
+ ("openai", "gpt-4-turbo") => ModelInfo {
37
+ context_window: 128000,
38
+ max_output: 4096,
39
+ training_cutoff: "Apr 2023",
40
+ capabilities: vec!["Text", "Code", "Vision", "JSON"],
41
+ },
42
+ ("openai", "gpt-3.5-turbo") => ModelInfo {
43
+ context_window: 16385,
44
+ max_output: 4096,
45
+ training_cutoff: "Sep 2021",
46
+ capabilities: vec!["Text", "Code"],
47
+ },
48
+
49
+ // Anthropic
50
+ ("anthropic", "claude-3-opus") => ModelInfo {
51
+ context_window: 200000,
52
+ max_output: 4096,
53
+ training_cutoff: "Aug 2023",
54
+ capabilities: vec!["Text", "Code", "Vision", "Analysis", "Long Context"],
55
+ },
56
+ ("anthropic", "claude-3-sonnet") => ModelInfo {
57
+ context_window: 200000,
58
+ max_output: 4096,
59
+ training_cutoff: "Aug 2023",
60
+ capabilities: vec!["Text", "Code", "Vision", "Balanced"],
61
+ },
62
+ ("anthropic", "claude-3-haiku") => ModelInfo {
63
+ context_window: 200000,
64
+ max_output: 4096,
65
+ training_cutoff: "Aug 2023",
66
+ capabilities: vec!["Text", "Code", "Fast", "Efficient"],
67
+ },
68
+
69
+ // Local models
70
+ _ => ModelInfo {
71
+ context_window: 4096,
72
+ max_output: 2048,
73
+ training_cutoff: "Varies",
74
+ capabilities: vec!["Text", "Local", "Private"],
75
+ },
76
+ }
77
+ }
78
+
79
+ // === COST CALCULATOR ===
80
+ /// Pricing data for different AI providers (per 1M tokens)
81
+ struct ModelPricing {
82
+ input_cost: f64, // USD per 1M input tokens
83
+ output_cost: f64, // USD per 1M output tokens
84
+ }
85
+
86
+ fn get_model_pricing(provider: &str, model: &str) -> ModelPricing {
87
+ match (provider, model) {
88
+ // OpenAI
89
+ ("openai", "gpt-4") => ModelPricing { input_cost: 30.0, output_cost: 60.0 },
90
+ ("openai", "gpt-4-turbo") => ModelPricing { input_cost: 10.0, output_cost: 30.0 },
91
+ ("openai", "gpt-3.5-turbo") => ModelPricing { input_cost: 0.5, output_cost: 1.5 },
92
+
93
+ // Anthropic
94
+ ("anthropic", "claude-3-opus") => ModelPricing { input_cost: 15.0, output_cost: 75.0 },
95
+ ("anthropic", "claude-3-sonnet") => ModelPricing { input_cost: 3.0, output_cost: 15.0 },
96
+ ("anthropic", "claude-3-haiku") => ModelPricing { input_cost: 0.25, output_cost: 1.25 },
97
+
98
+ // Local models (free)
99
+ _ => ModelPricing { input_cost: 0.0, output_cost: 0.0 },
100
+ }
101
+ }
102
+
103
+ struct CostEstimate {
104
+ cost_1k: f64,
105
+ cost_10k: f64,
106
+ cost_100k: f64,
107
+ cost_1m: f64,
108
+ is_free: bool,
109
+ }
110
+
111
+ fn calculate_cost(provider: &str, model: &str, max_tokens: u32) -> CostEstimate {
112
+ let pricing = get_model_pricing(provider, model);
113
+ let is_free = pricing.input_cost == 0.0 && pricing.output_cost == 0.0;
114
+
115
+ if is_free {
116
+ return CostEstimate {
117
+ cost_1k: 0.0,
118
+ cost_10k: 0.0,
119
+ cost_100k: 0.0,
120
+ cost_1m: 0.0,
121
+ is_free: true,
122
+ };
123
+ }
124
+
125
+ // Assume 50/50 split between input and output tokens for estimation
126
+ let avg_cost_per_1m = (pricing.input_cost + pricing.output_cost) / 2.0;
127
+
128
+ CostEstimate {
129
+ cost_1k: avg_cost_per_1m / 1000.0,
130
+ cost_10k: avg_cost_per_1m / 100.0,
131
+ cost_100k: avg_cost_per_1m / 10.0,
132
+ cost_1m: avg_cost_per_1m,
133
+ is_free: false,
134
+ }
135
+ }
136
+
18
137
  /// Agent Builder wizard state
19
138
  #[derive(Debug, Clone)]
20
139
  pub struct AgentBuilderState {
@@ -188,16 +307,50 @@ pub fn render(f: &mut Frame, state: &AppState) {
188
307
  }
189
308
 
190
309
  fn render_wizard(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
310
+ use ratatui::layout::{Constraint, Direction, Layout};
311
+
312
+ // Split area into content and footer
313
+ let v_chunks = Layout::default()
314
+ .direction(Direction::Vertical)
315
+ .constraints([
316
+ Constraint::Min(10), // Content
317
+ Constraint::Length(3), // Navigation footer
318
+ ])
319
+ .split(area);
320
+
321
+ // Split content into main wizard and cost calculator (if on step 2 or 4)
322
+ let show_cost_calc = wizard.current_step == 2 || wizard.current_step == 4;
323
+ let h_chunks = Layout::default()
324
+ .direction(Direction::Horizontal)
325
+ .constraints(if show_cost_calc {
326
+ [
327
+ Constraint::Percentage(65), // Wizard content
328
+ Constraint::Percentage(35), // Cost calculator
329
+ ].as_ref()
330
+ } else {
331
+ [
332
+ Constraint::Percentage(100), // Full width
333
+ Constraint::Length(0), // No cost calc
334
+ ].as_ref()
335
+ })
336
+ .split(v_chunks[0]);
337
+
338
+ // Progress bar for title
339
+ let progress = wizard.current_step as f32 / 6.0;
340
+ let filled = (progress * 20.0) as usize;
341
+ let empty = 20 - filled;
342
+ let progress_bar = format!("[{}{}]", "█".repeat(filled), "░".repeat(empty));
343
+
191
344
  // Main container with brand styling
192
345
  let block = Block::default()
193
- .title(format!(" Agent Builder - Step {}/6 ", wizard.current_step))
346
+ .title(format!(" 🤖 Agent Builder - Step {}/6 {} ", wizard.current_step, progress_bar))
194
347
  .title_style(Style::default().fg(CYBER_CYAN).add_modifier(Modifier::BOLD))
195
348
  .borders(Borders::ALL)
196
349
  .border_style(Style::default().fg(BRAND_PURPLE))
197
350
  .style(Style::default().bg(BG_PANEL));
198
351
 
199
- let inner = block.inner(area);
200
- f.render_widget(block, area);
352
+ let inner = block.inner(h_chunks[0]);
353
+ f.render_widget(block, h_chunks[0]);
201
354
 
202
355
  // Render current step
203
356
  match wizard.current_step {
@@ -209,6 +362,208 @@ fn render_wizard(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
209
362
  6 => render_step_6_review(f, inner, wizard),
210
363
  _ => {}
211
364
  }
365
+
366
+ // Render cost calculator if visible
367
+ if show_cost_calc {
368
+ render_cost_calculator(f, h_chunks[1], wizard);
369
+ }
370
+
371
+ // Render navigation footer
372
+ render_navigation_footer(f, v_chunks[1], wizard);
373
+ }
374
+
375
+ fn render_cost_calculator(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
376
+ use ratatui::layout::{Constraint, Direction, Layout};
377
+
378
+ // Split into cost and model info sections
379
+ let chunks = Layout::default()
380
+ .direction(Direction::Vertical)
381
+ .constraints([
382
+ Constraint::Percentage(60), // Cost info
383
+ Constraint::Percentage(40), // Model info
384
+ ])
385
+ .split(area);
386
+
387
+ let provider = if wizard.use_local_model {
388
+ "local"
389
+ } else {
390
+ &wizard.provider
391
+ };
392
+ let model = if wizard.use_local_model {
393
+ &wizard.local_model
394
+ } else {
395
+ &wizard.model
396
+ };
397
+
398
+ let cost = calculate_cost(provider, model, wizard.max_tokens);
399
+ let model_info = get_model_info(provider, model);
400
+
401
+ // === COST SECTION ===
402
+ let cost_block = Block::default()
403
+ .title(" 💰 Cost Estimator ")
404
+ .borders(Borders::ALL)
405
+ .border_style(Style::default().fg(AMBER_WARN))
406
+ .style(Style::default().bg(BG_PANEL));
407
+
408
+ let cost_inner = cost_block.inner(chunks[0]);
409
+ f.render_widget(cost_block, chunks[0]);
410
+
411
+ let text = if cost.is_free {
412
+ vec![
413
+ Line::from(""),
414
+ Line::from(vec![
415
+ Span::styled("✓ ", Style::default().fg(NEON_GREEN)),
416
+ Span::styled("Local Model", Style::default().fg(NEON_GREEN).bold()),
417
+ ]),
418
+ Line::from(""),
419
+ Line::from(vec![
420
+ Span::styled("FREE", Style::default().fg(NEON_GREEN).bold().add_modifier(Modifier::UNDERLINED)),
421
+ ]),
422
+ Line::from(""),
423
+ Line::from("No API costs!").style(Style::default().fg(TEXT_DIM)),
424
+ Line::from("Self-hosted model").style(Style::default().fg(TEXT_DIM)),
425
+ Line::from(""),
426
+ Line::from("Benefits:").style(Style::default().fg(TEXT_PRIMARY).bold()),
427
+ Line::from(" • No usage limits").style(Style::default().fg(TEXT_DIM)),
428
+ Line::from(" • Full privacy").style(Style::default().fg(TEXT_DIM)),
429
+ Line::from(" • No API keys").style(Style::default().fg(TEXT_DIM)),
430
+ ]
431
+ } else {
432
+ let cost_color = if cost.cost_1k < 0.01 {
433
+ NEON_GREEN
434
+ } else if cost.cost_1k < 0.05 {
435
+ AMBER_WARN
436
+ } else {
437
+ Color::Rgb(255, 69, 69)
438
+ };
439
+
440
+ vec![
441
+ Line::from(""),
442
+ Line::from(vec![
443
+ Span::styled("Provider: ", Style::default().fg(TEXT_DIM)),
444
+ Span::styled(provider, Style::default().fg(TEXT_PRIMARY).bold()),
445
+ ]),
446
+ Line::from(vec![
447
+ Span::styled("Model: ", Style::default().fg(TEXT_DIM)),
448
+ Span::styled(model, Style::default().fg(TEXT_PRIMARY)),
449
+ ]),
450
+ Line::from(""),
451
+ Line::from("Estimated Cost:").style(Style::default().fg(TEXT_PRIMARY).bold()),
452
+ Line::from(""),
453
+ Line::from(vec![
454
+ Span::styled(" 1K tokens: ", Style::default().fg(TEXT_DIM)),
455
+ Span::styled(format!("${:.4}", cost.cost_1k), Style::default().fg(cost_color)),
456
+ ]),
457
+ Line::from(vec![
458
+ Span::styled(" 10K tokens: ", Style::default().fg(TEXT_DIM)),
459
+ Span::styled(format!("${:.3}", cost.cost_10k), Style::default().fg(cost_color)),
460
+ ]),
461
+ Line::from(vec![
462
+ Span::styled(" 100K tokens: ", Style::default().fg(TEXT_DIM)),
463
+ Span::styled(format!("${:.2}", cost.cost_100k), Style::default().fg(cost_color)),
464
+ ]),
465
+ Line::from(vec![
466
+ Span::styled(" 1M tokens: ", Style::default().fg(TEXT_DIM)),
467
+ Span::styled(format!("${:.2}", cost.cost_1m), Style::default().fg(cost_color)),
468
+ ]),
469
+ Line::from(""),
470
+ Line::from(vec![
471
+ Span::styled("Max tokens: ", Style::default().fg(TEXT_DIM)),
472
+ Span::styled(format!("{}", wizard.max_tokens), Style::default().fg(CYBER_CYAN)),
473
+ ]),
474
+ ]
475
+ };
476
+
477
+ let cost_paragraph = Paragraph::new(text)
478
+ .wrap(Wrap { trim: false })
479
+ .alignment(Alignment::Left);
480
+
481
+ f.render_widget(cost_paragraph, cost_inner);
482
+
483
+ // === MODEL INFO SECTION ===
484
+ let info_block = Block::default()
485
+ .title(" 📊 Model Info ")
486
+ .borders(Borders::ALL)
487
+ .border_style(Style::default().fg(CYBER_CYAN))
488
+ .style(Style::default().bg(BG_PANEL));
489
+
490
+ let info_inner = info_block.inner(chunks[1]);
491
+ f.render_widget(info_block, chunks[1]);
492
+
493
+ let info_text = vec![
494
+ Line::from(""),
495
+ Line::from(vec![
496
+ Span::styled("Context: ", Style::default().fg(TEXT_DIM)),
497
+ Span::styled(format!("{} tokens", model_info.context_window), Style::default().fg(NEON_GREEN)),
498
+ ]),
499
+ Line::from(vec![
500
+ Span::styled("Max Output: ", Style::default().fg(TEXT_DIM)),
501
+ Span::styled(format!("{} tokens", model_info.max_output), Style::default().fg(TEXT_PRIMARY)),
502
+ ]),
503
+ Line::from(vec![
504
+ Span::styled("Training: ", Style::default().fg(TEXT_DIM)),
505
+ Span::styled(model_info.training_cutoff, Style::default().fg(TEXT_MUTED)),
506
+ ]),
507
+ Line::from(""),
508
+ Line::from("Capabilities:").style(Style::default().fg(TEXT_PRIMARY).bold()),
509
+ Line::from(
510
+ model_info.capabilities
511
+ .iter()
512
+ .map(|cap| format!(" • {}", cap))
513
+ .collect::<Vec<_>>()
514
+ .join("\n")
515
+ ).style(Style::default().fg(TEXT_DIM)),
516
+ ];
517
+
518
+ let info_paragraph = Paragraph::new(info_text)
519
+ .wrap(Wrap { trim: false })
520
+ .alignment(Alignment::Left);
521
+
522
+ f.render_widget(info_paragraph, info_inner);
523
+ }
524
+
525
+ fn render_navigation_footer(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
526
+ let block = Block::default()
527
+ .title(" ⌨️ Navigation ")
528
+ .borders(Borders::ALL)
529
+ .border_style(Style::default().fg(TEXT_DIM))
530
+ .style(Style::default().bg(BG_PANEL));
531
+
532
+ let inner = block.inner(area);
533
+ f.render_widget(block, area);
534
+
535
+ let can_go_back = wizard.current_step > 1;
536
+ let is_final_step = wizard.current_step == 6;
537
+
538
+ let nav_text = if is_final_step {
539
+ Line::from(vec![
540
+ Span::styled("←", Style::default().fg(if can_go_back { CYBER_CYAN } else { TEXT_MUTED }).bold()),
541
+ Span::styled(" Back │ ", Style::default().fg(TEXT_DIM)),
542
+ Span::styled("Tab/↑↓", Style::default().fg(CYBER_CYAN).bold()),
543
+ Span::styled(" Navigate │ ", Style::default().fg(TEXT_DIM)),
544
+ Span::styled("Enter", Style::default().fg(NEON_GREEN).bold()),
545
+ Span::styled(" Create Agent │ ", Style::default().fg(TEXT_DIM)),
546
+ Span::styled("ESC", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
547
+ Span::styled(" Cancel", Style::default().fg(TEXT_DIM)),
548
+ ])
549
+ } else {
550
+ Line::from(vec![
551
+ Span::styled("←", Style::default().fg(if can_go_back { CYBER_CYAN } else { TEXT_MUTED }).bold()),
552
+ Span::styled(" Back │ ", Style::default().fg(TEXT_DIM)),
553
+ Span::styled("Tab/↑↓", Style::default().fg(CYBER_CYAN).bold()),
554
+ Span::styled(" Navigate │ ", Style::default().fg(TEXT_DIM)),
555
+ Span::styled("→/Enter", Style::default().fg(NEON_GREEN).bold()),
556
+ Span::styled(" Next │ ", Style::default().fg(TEXT_DIM)),
557
+ Span::styled("ESC", Style::default().fg(Color::Rgb(255, 69, 69)).bold()),
558
+ Span::styled(" Cancel", Style::default().fg(TEXT_DIM)),
559
+ ])
560
+ };
561
+
562
+ let paragraph = Paragraph::new(nav_text)
563
+ .style(Style::default().fg(TEXT_PRIMARY))
564
+ .alignment(Alignment::Center);
565
+
566
+ f.render_widget(paragraph, inner);
212
567
  }
213
568
 
214
569
  fn render_step_1_basic_info(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
@@ -460,68 +815,104 @@ fn render_step_5_tools(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
460
815
  }
461
816
 
462
817
  fn render_step_6_review(f: &mut Frame, area: Rect, wizard: &AgentBuilderState) {
463
- let model_info = if wizard.use_local_model {
818
+ let provider = if wizard.use_local_model {
819
+ "local"
820
+ } else {
821
+ &wizard.provider
822
+ };
823
+ let model = if wizard.use_local_model {
824
+ &wizard.local_model
825
+ } else {
826
+ &wizard.model
827
+ };
828
+
829
+ let model_info_str = if wizard.use_local_model {
464
830
  format!("{} ({})", wizard.local_model, wizard.local_provider)
465
831
  } else {
466
832
  format!("{} ({})", wizard.model, wizard.provider)
467
833
  };
468
834
 
469
- let text = vec![
835
+ let cost = calculate_cost(provider, model, wizard.max_tokens);
836
+ let model_info = get_model_info(provider, model);
837
+
838
+ let mut text = vec![
470
839
  Line::from(""),
471
840
  Line::from("Step 6: Review & Confirm").style(Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
472
841
  Line::from(""),
473
- Line::from("Please review your agent configuration:").style(Style::default().fg(TEXT_PRIMARY)),
842
+ Line::from(" Configuration Complete - Ready to Create").style(Style::default().fg(NEON_GREEN).bold()),
474
843
  Line::from(""),
475
844
  Line::from("═══════════════════════════════════════════════════").style(Style::default().fg(TEXT_MUTED)),
845
+ Line::from(""),
846
+ Line::from("Basic Info:").style(Style::default().fg(CYBER_CYAN).bold()),
476
847
  Line::from(vec![
477
- Span::raw("Name: "),
848
+ Span::styled(" Name: ", Style::default().fg(TEXT_DIM)),
478
849
  Span::styled(&wizard.name, Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
479
850
  ]),
480
851
  Line::from(vec![
481
- Span::raw("Description: "),
852
+ Span::styled(" Description: ", Style::default().fg(TEXT_DIM)),
482
853
  Span::styled(
483
- wizard.description.as_str(),
484
- Style::default().fg(CYBER_CYAN)
854
+ if wizard.description.is_empty() { "(none)" } else { wizard.description.as_str() },
855
+ Style::default().fg(TEXT_PRIMARY)
485
856
  ),
486
857
  ]),
487
858
  Line::from(""),
859
+ Line::from("Model Configuration:").style(Style::default().fg(CYBER_CYAN).bold()),
488
860
  Line::from(vec![
489
- Span::raw("Model: "),
490
- Span::styled(&model_info, Style::default().fg(CYBER_CYAN)),
861
+ Span::styled(" Model: ", Style::default().fg(TEXT_DIM)),
862
+ Span::styled(&model_info_str, Style::default().fg(TEXT_PRIMARY).bold()),
491
863
  ]),
492
864
  Line::from(vec![
493
- Span::raw("Temperature: "),
494
- Span::styled(format!("{:.2}", wizard.temperature), Style::default().fg(CYBER_CYAN)),
865
+ Span::styled(" Context Window: ", Style::default().fg(TEXT_DIM)),
866
+ Span::styled(format!("{} tokens", model_info.context_window), Style::default().fg(TEXT_PRIMARY)),
495
867
  ]),
496
868
  Line::from(vec![
497
- Span::raw("Max Tokens: "),
498
- Span::styled(format!("{}", wizard.max_tokens), Style::default().fg(CYBER_CYAN)),
869
+ Span::styled(" Temperature: ", Style::default().fg(TEXT_DIM)),
870
+ Span::styled(format!("{:.2}", wizard.temperature), Style::default().fg(TEXT_PRIMARY)),
871
+ ]),
872
+ Line::from(vec![
873
+ Span::styled(" Max Tokens: ", Style::default().fg(TEXT_DIM)),
874
+ Span::styled(format!("{}", wizard.max_tokens), Style::default().fg(TEXT_PRIMARY)),
499
875
  ]),
500
876
  Line::from(""),
877
+ Line::from("Tools:").style(Style::default().fg(CYBER_CYAN).bold()),
501
878
  Line::from(vec![
502
- Span::raw("Tools: "),
879
+ Span::styled(" ", Style::default()),
503
880
  Span::styled(
504
881
  if wizard.selected_tools.is_empty() {
505
- "None".to_string()
882
+ "None selected".to_string()
506
883
  } else {
507
884
  wizard.selected_tools.join(", ")
508
885
  },
509
- Style::default().fg(CYBER_CYAN)
886
+ Style::default().fg(TEXT_PRIMARY)
510
887
  ),
511
888
  ]),
512
- Line::from(""),
513
- Line::from("System Prompt:").style(Style::default().fg(TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
514
- Line::from(if wizard.system_prompt.is_empty() {
515
- " (none)".to_string()
516
- } else {
517
- format!(" {}", wizard.system_prompt.chars().take(100).collect::<String>())
518
- }).style(Style::default().fg(TEXT_DIM)),
519
- Line::from("═══════════════════════════════════════════════════").style(Style::default().fg(TEXT_MUTED)),
520
- Line::from(""),
521
- Line::from("Actions:").style(Style::default().fg(NEON_GREEN).add_modifier(Modifier::BOLD)),
522
- Line::from(" Enter - Create Agent | ESC - Cancel").style(Style::default().fg(TEXT_DIM)),
523
889
  ];
524
890
 
891
+ // Add cost estimate
892
+ if !cost.is_free {
893
+ text.push(Line::from(""));
894
+ text.push(Line::from("Estimated Cost:").style(Style::default().fg(AMBER_WARN).bold()));
895
+ text.push(Line::from(vec![
896
+ Span::styled(" Per 1K tokens: ", Style::default().fg(TEXT_DIM)),
897
+ Span::styled(format!("${:.4}", cost.cost_1k), Style::default().fg(TEXT_PRIMARY)),
898
+ ]));
899
+ } else {
900
+ text.push(Line::from(""));
901
+ text.push(Line::from(vec![
902
+ Span::styled(" ✓ ", Style::default().fg(NEON_GREEN)),
903
+ Span::styled("FREE (Local Model)", Style::default().fg(NEON_GREEN).bold()),
904
+ ]));
905
+ }
906
+
907
+ text.push(Line::from(""));
908
+ text.push(Line::from("═══════════════════════════════════════════════════").style(Style::default().fg(TEXT_MUTED)));
909
+ text.push(Line::from(""));
910
+ text.push(Line::from(vec![
911
+ Span::styled("Press ", Style::default().fg(TEXT_DIM)),
912
+ Span::styled("Enter", Style::default().fg(NEON_GREEN).bold()),
913
+ Span::styled(" to create this agent", Style::default().fg(TEXT_DIM)),
914
+ ]));
915
+
525
916
  let paragraph = Paragraph::new(text)
526
917
  .wrap(Wrap { trim: false })
527
918
  .alignment(Alignment::Left);
@@ -135,7 +135,7 @@ pub fn render_boot(f: &mut Frame, area: Rect, state: &AppState) {
135
135
  x: area.x,
136
136
  y: current_y,
137
137
  width: area.width,
138
- height: footer_height,
138
+ height: 1,
139
139
  };
140
140
 
141
141
  f.render_widget(
@@ -120,7 +120,7 @@ fn render_header(f: &mut Frame, area: Rect, state: &AppState) {
120
120
 
121
121
  // Line 1: Brand + version + uptime - Bug 3 fix: Use "4Runr." with dot (matches brand logo)
122
122
  // Use npm package version (2.3.5) - matches package.json
123
- const PACKAGE_VERSION: &str = "2.3.8";
123
+ const PACKAGE_VERSION: &str = "2.4.0";
124
124
  let brand_line = Line::from(vec![
125
125
  Span::styled("4Runr.", Style::default().fg(BRAND_PURPLE).add_modifier(Modifier::BOLD)),
126
126
  Span::styled(" AI AGENT OS", Style::default().fg(BRAND_VIOLET)),